Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
4.8 MiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "automatedigital/spark", "files": {"src/spark_cli/config.py": {"d44192c3c2d4295e0ad36e029ab6a8d94dde43c6": {"t": 1785746887.0580513, "n": 152845, "h": "f627f4932eb57696", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file) as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n # User-facing name for the agent, set during onboarding. Surfaced in the\n # system prompt so the agent knows what to call itself.\n \"name\": \"Spark\",\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Dependency-aware effect scheduler. Set false to restore the\n # conservative sequential executor for newly created agent runs.\n \"dependency_scheduler\": True,\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow browser/CDP access to private/internal IPs beyond loopback previews\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n \"response_budget\": {\n # Cache-stable action-first guidance. Adaptive model routing remains\n # independently controlled by smart_model_routing.enabled.\n \"style_enabled\": True,\n \"soft_output_caps\": True,\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n # WebUI Preview-tab streamed browser backend:\n # \"auto\" -> use agent-browser when available, else fall back to Playwright\n # \"agent-browser\" -> always agent-browser (shares the agent's browser session)\n # \"playwright\" -> always the Playwright StreamedBrowserSession\n \"preview_browser_backend\": \"auto\",\n \"chat_word_wrap\": False, # WebUI: default word wrap for chat code blocks and markdown tables\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Anthropic, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n \"onboarding_mode\": \"\", # \"recommended\" | \"minimal\" | \"none\" — set during setup\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n # When True, the agent browser pauses before sensitive actions\n # (submitting payments, sending messages, logging into a NEW domain)\n # and returns a needs_confirmation result the user must approve before\n # the action runs. See src/tools/browser_permission_gate.py.\n \"browser_confirm_sensitive\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n # Optional PEM bundle of trusted CAs for provider HTTPS calls.\n # Supports ~ and ${ENV_VAR} expansion.\n \"ca_bundle\": \"\",\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n # Show first-class delegated sub-agents in the chat right sidebar.\n # Default on for v1, but keep a kill switch while the lifecycle API settles.\n \"subagents_sidebar\": True,\n },\n # macOS / desktop app behaviour.\n \"desktop\": {\n # When true, the desktop app auto-starts the messaging gateway in the\n # background on launch (so platforms like Telegram/Discord can reach\n # Spark while the app is open) and stops it on quit. Set false to opt\n # out — the app then never spawns its own gateway and leaves any\n # user-managed external gateway untouched.\n \"gateway_autostart\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"workflow_in_gateway\": False,\n \"workflow_interval_seconds\": 10,\n \"notify_on_changes\": False,\n \"wake_creator_on_changes\": False,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 28,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (OpenRouter / Anthropic / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override (hosted managed gateways are disabled)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for derived vendor hosts (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for derived vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit hosted tool-gateway access token (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\n# Advisory per-platform enable/disable toggles persisted by the Messaging\n# settings UI (see gateway/platform_fields.py). Registered here so\n# reload_env() tracks them (sync + removal). WHATSAPP_ENABLED,\n# WEBHOOK_ENABLED, and API_SERVER_ENABLED are declared individually above.\nfor _flag, _platform_name in (\n (\"TELEGRAM_ENABLED\", \"Telegram\"),\n (\"DISCORD_ENABLED\", \"Discord\"),\n (\"SLACK_ENABLED\", \"Slack\"),\n (\"MATTERMOST_ENABLED\", \"Mattermost\"),\n (\"MATRIX_ENABLED\", \"Matrix\"),\n (\"SIGNAL_ENABLED\", \"Signal\"),\n (\"BLUEBUBBLES_ENABLED\", \"BlueBubbles (iMessage)\"),\n (\"HOMEASSISTANT_ENABLED\", \"Home Assistant\"),\n (\"EMAIL_ENABLED\", \"Email\"),\n (\"SMS_ENABLED\", \"SMS\"),\n (\"DINGTALK_ENABLED\", \"DingTalk\"),\n (\"FEISHU_ENABLED\", \"Feishu/Lark\"),\n (\"WECOM_ENABLED\", \"WeCom\"),\n (\"WECOM_CALLBACK_ENABLED\", \"WeCom Callback\"),\n (\"WEIXIN_ENABLED\", \"WeChat Official Account\"),\n (\"QQBOT_ENABLED\", \"QQ Bot\"),\n):\n OPTIONAL_ENV_VARS.setdefault(\n _flag,\n {\n \"description\": (\n f\"Enable/disable the {_platform_name} messaging platform \"\n \"(true/false). Auto-managed when credentials are saved in \"\n \"Messaging > Settings.\"\n ),\n \"prompt\": f\"Enable {_platform_name} (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n )\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, (dict, list)) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"url\",\n \"api\",\n \"api_key\",\n \"key_env\",\n \"api_mode\",\n \"transport\",\n \"default_model\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n_VALID_PROVIDER_TRANSPORTS = {\n \"openai_chat\",\n \"chat_completions\",\n \"anthropic_messages\",\n \"codex_responses\",\n \"responses\",\n}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── providers must be a keyed dict of OpenAI-compatible endpoints ─────\n providers_cfg = config.get(\"providers\")\n if providers_cfg is not None:\n if not isinstance(providers_cfg, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"providers should be a dict keyed by provider id, \"\n f\"got {type(providers_cfg).__name__}\",\n \"Change to:\\n\"\n \" providers:\\n\"\n \" local-llm:\\n\"\n \" api: http://localhost:11434/v1\\n\"\n \" default_model: llama3\",\n )\n )\n else:\n for provider_key, entry in providers_cfg.items():\n label = f\"providers.{provider_key}\"\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label} is not a dict (got {type(entry).__name__})\",\n \"Each providers entry should contain at minimum an \"\n \"api/base_url and optional models.\",\n )\n )\n continue\n api_url = (\n entry.get(\"api\") or entry.get(\"url\") or entry.get(\"base_url\") or \"\"\n )\n if not isinstance(api_url, str) or not api_url.strip():\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label} is missing an api/url/base_url endpoint\",\n \"Add an OpenAI-compatible endpoint, e.g. api: http://localhost:11434/v1\",\n )\n )\n key_env = entry.get(\"key_env\")\n if key_env is not None and (\n not isinstance(key_env, str)\n or not _ENV_VAR_NAME_RE.match(key_env.strip())\n ):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.key_env is not a valid environment variable name\",\n \"Use only letters, numbers, and underscores, starting with a letter or underscore.\",\n )\n )\n transport = entry.get(\"transport\", entry.get(\"api_mode\"))\n if transport is not None:\n transport_value = str(transport or \"\").strip().lower()\n if transport_value and transport_value not in _VALID_PROVIDER_TRANSPORTS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.transport has unsupported value '{transport}'\",\n \"Use openai_chat, chat_completions, \"\n \"anthropic_messages, or codex_responses.\",\n )\n )\n models = entry.get(\"models\")\n if models is not None:\n valid_models = isinstance(models, list) and all(\n isinstance(m, str) and m.strip() for m in models\n )\n valid_model_meta = isinstance(models, dict)\n if not (valid_models or valid_model_meta):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.models should be a list of model ids\",\n \"Example: models: [llama3, qwen3-coder]\",\n )\n )\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"workflow_in_gateway\": False,\n \"workflow_interval_seconds\": 10,\n \"notify_on_changes\": False,\n \"wake_creator_on_changes\": False,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # -- Version 26 -> 27: add Kanban notification/wake workflow defaults --\n if current_ver < 27:\n config = read_raw_config()\n kanban = config.setdefault(\"kanban\", {})\n changed = False\n if isinstance(kanban, dict):\n for key, value in {\n \"workflow_in_gateway\": False,\n \"workflow_interval_seconds\": 10,\n \"notify_on_changes\": False,\n \"wake_creator_on_changes\": False,\n }.items():\n if key not in kanban:\n kanban[key] = value\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added kanban workflow notification/wake defaults\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n # ── Version 22 → 23: add display.preview_browser_backend (WebUI browser pane) ──\n if current_ver < 23:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"preview_browser_backend\" not in display:\n display[\"preview_browser_backend\"] = \"auto\"\n config[\"display\"] = display\n save_config(config)\n results[\"config_added\"].append(\"display.preview_browser_backend=auto (default)\")\n if not quiet:\n print(\" ✓ Added display.preview_browser_backend=auto\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "3ccb49f7de5eaf608a4d6d2a4e3a73e211009ad6": {"t": 1785768508.253226, "n": 152845, "h": "f627f4932eb57696", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file) as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n # User-facing name for the agent, set during onboarding. Surfaced in the\n # system prompt so the agent knows what to call itself.\n \"name\": \"Spark\",\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Dependency-aware effect scheduler. Set false to restore the\n # conservative sequential executor for newly created agent runs.\n \"dependency_scheduler\": True,\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow browser/CDP access to private/internal IPs beyond loopback previews\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n \"response_budget\": {\n # Cache-stable action-first guidance. Adaptive model routing remains\n # independently controlled by smart_model_routing.enabled.\n \"style_enabled\": True,\n \"soft_output_caps\": True,\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n # WebUI Preview-tab streamed browser backend:\n # \"auto\" -> use agent-browser when available, else fall back to Playwright\n # \"agent-browser\" -> always agent-browser (shares the agent's browser session)\n # \"playwright\" -> always the Playwright StreamedBrowserSession\n \"preview_browser_backend\": \"auto\",\n \"chat_word_wrap\": False, # WebUI: default word wrap for chat code blocks and markdown tables\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Anthropic, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n \"onboarding_mode\": \"\", # \"recommended\" | \"minimal\" | \"none\" — set during setup\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n # When True, the agent browser pauses before sensitive actions\n # (submitting payments, sending messages, logging into a NEW domain)\n # and returns a needs_confirmation result the user must approve before\n # the action runs. See src/tools/browser_permission_gate.py.\n \"browser_confirm_sensitive\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n # Optional PEM bundle of trusted CAs for provider HTTPS calls.\n # Supports ~ and ${ENV_VAR} expansion.\n \"ca_bundle\": \"\",\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n # Show first-class delegated sub-agents in the chat right sidebar.\n # Default on for v1, but keep a kill switch while the lifecycle API settles.\n \"subagents_sidebar\": True,\n },\n # macOS / desktop app behaviour.\n \"desktop\": {\n # When true, the desktop app auto-starts the messaging gateway in the\n # background on launch (so platforms like Telegram/Discord can reach\n # Spark while the app is open) and stops it on quit. Set false to opt\n # out — the app then never spawns its own gateway and leaves any\n # user-managed external gateway untouched.\n \"gateway_autostart\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"workflow_in_gateway\": False,\n \"workflow_interval_seconds\": 10,\n \"notify_on_changes\": False,\n \"wake_creator_on_changes\": False,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 28,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (OpenRouter / Anthropic / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override (hosted managed gateways are disabled)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for derived vendor hosts (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for derived vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit hosted tool-gateway access token (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\n# Advisory per-platform enable/disable toggles persisted by the Messaging\n# settings UI (see gateway/platform_fields.py). Registered here so\n# reload_env() tracks them (sync + removal). WHATSAPP_ENABLED,\n# WEBHOOK_ENABLED, and API_SERVER_ENABLED are declared individually above.\nfor _flag, _platform_name in (\n (\"TELEGRAM_ENABLED\", \"Telegram\"),\n (\"DISCORD_ENABLED\", \"Discord\"),\n (\"SLACK_ENABLED\", \"Slack\"),\n (\"MATTERMOST_ENABLED\", \"Mattermost\"),\n (\"MATRIX_ENABLED\", \"Matrix\"),\n (\"SIGNAL_ENABLED\", \"Signal\"),\n (\"BLUEBUBBLES_ENABLED\", \"BlueBubbles (iMessage)\"),\n (\"HOMEASSISTANT_ENABLED\", \"Home Assistant\"),\n (\"EMAIL_ENABLED\", \"Email\"),\n (\"SMS_ENABLED\", \"SMS\"),\n (\"DINGTALK_ENABLED\", \"DingTalk\"),\n (\"FEISHU_ENABLED\", \"Feishu/Lark\"),\n (\"WECOM_ENABLED\", \"WeCom\"),\n (\"WECOM_CALLBACK_ENABLED\", \"WeCom Callback\"),\n (\"WEIXIN_ENABLED\", \"WeChat Official Account\"),\n (\"QQBOT_ENABLED\", \"QQ Bot\"),\n):\n OPTIONAL_ENV_VARS.setdefault(\n _flag,\n {\n \"description\": (\n f\"Enable/disable the {_platform_name} messaging platform \"\n \"(true/false). Auto-managed when credentials are saved in \"\n \"Messaging > Settings.\"\n ),\n \"prompt\": f\"Enable {_platform_name} (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n )\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, (dict, list)) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"url\",\n \"api\",\n \"api_key\",\n \"key_env\",\n \"api_mode\",\n \"transport\",\n \"default_model\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n_VALID_PROVIDER_TRANSPORTS = {\n \"openai_chat\",\n \"chat_completions\",\n \"anthropic_messages\",\n \"codex_responses\",\n \"responses\",\n}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── providers must be a keyed dict of OpenAI-compatible endpoints ─────\n providers_cfg = config.get(\"providers\")\n if providers_cfg is not None:\n if not isinstance(providers_cfg, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"providers should be a dict keyed by provider id, \"\n f\"got {type(providers_cfg).__name__}\",\n \"Change to:\\n\"\n \" providers:\\n\"\n \" local-llm:\\n\"\n \" api: http://localhost:11434/v1\\n\"\n \" default_model: llama3\",\n )\n )\n else:\n for provider_key, entry in providers_cfg.items():\n label = f\"providers.{provider_key}\"\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label} is not a dict (got {type(entry).__name__})\",\n \"Each providers entry should contain at minimum an \"\n \"api/base_url and optional models.\",\n )\n )\n continue\n api_url = (\n entry.get(\"api\") or entry.get(\"url\") or entry.get(\"base_url\") or \"\"\n )\n if not isinstance(api_url, str) or not api_url.strip():\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label} is missing an api/url/base_url endpoint\",\n \"Add an OpenAI-compatible endpoint, e.g. api: http://localhost:11434/v1\",\n )\n )\n key_env = entry.get(\"key_env\")\n if key_env is not None and (\n not isinstance(key_env, str)\n or not _ENV_VAR_NAME_RE.match(key_env.strip())\n ):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.key_env is not a valid environment variable name\",\n \"Use only letters, numbers, and underscores, starting with a letter or underscore.\",\n )\n )\n transport = entry.get(\"transport\", entry.get(\"api_mode\"))\n if transport is not None:\n transport_value = str(transport or \"\").strip().lower()\n if transport_value and transport_value not in _VALID_PROVIDER_TRANSPORTS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.transport has unsupported value '{transport}'\",\n \"Use openai_chat, chat_completions, \"\n \"anthropic_messages, or codex_responses.\",\n )\n )\n models = entry.get(\"models\")\n if models is not None:\n valid_models = isinstance(models, list) and all(\n isinstance(m, str) and m.strip() for m in models\n )\n valid_model_meta = isinstance(models, dict)\n if not (valid_models or valid_model_meta):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.models should be a list of model ids\",\n \"Example: models: [llama3, qwen3-coder]\",\n )\n )\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"workflow_in_gateway\": False,\n \"workflow_interval_seconds\": 10,\n \"notify_on_changes\": False,\n \"wake_creator_on_changes\": False,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # -- Version 26 -> 27: add Kanban notification/wake workflow defaults --\n if current_ver < 27:\n config = read_raw_config()\n kanban = config.setdefault(\"kanban\", {})\n changed = False\n if isinstance(kanban, dict):\n for key, value in {\n \"workflow_in_gateway\": False,\n \"workflow_interval_seconds\": 10,\n \"notify_on_changes\": False,\n \"wake_creator_on_changes\": False,\n }.items():\n if key not in kanban:\n kanban[key] = value\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added kanban workflow notification/wake defaults\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n # ── Version 22 → 23: add display.preview_browser_backend (WebUI browser pane) ──\n if current_ver < 23:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"preview_browser_backend\" not in display:\n display[\"preview_browser_backend\"] = \"auto\"\n config[\"display\"] = display\n save_config(config)\n results[\"config_added\"].append(\"display.preview_browser_backend=auto (default)\")\n if not quiet:\n print(\" ✓ Added display.preview_browser_backend=auto\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "8249d95b9ed21f57e9bbd05305de92f48e2f55e2": {"t": 1785768510.0834696, "n": 152664, "h": "fddfe114c8015072", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file) as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n # User-facing name for the agent, set during onboarding. Surfaced in the\n # system prompt so the agent knows what to call itself.\n \"name\": \"Spark\",\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow browser/CDP access to private/internal IPs beyond loopback previews\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n \"response_budget\": {\n # Cache-stable action-first guidance. Adaptive model routing remains\n # independently controlled by smart_model_routing.enabled.\n \"style_enabled\": True,\n \"soft_output_caps\": True,\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n # WebUI Preview-tab streamed browser backend:\n # \"auto\" -> use agent-browser when available, else fall back to Playwright\n # \"agent-browser\" -> always agent-browser (shares the agent's browser session)\n # \"playwright\" -> always the Playwright StreamedBrowserSession\n \"preview_browser_backend\": \"auto\",\n \"chat_word_wrap\": False, # WebUI: default word wrap for chat code blocks and markdown tables\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Anthropic, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n \"onboarding_mode\": \"\", # \"recommended\" | \"minimal\" | \"none\" — set during setup\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n # When True, the agent browser pauses before sensitive actions\n # (submitting payments, sending messages, logging into a NEW domain)\n # and returns a needs_confirmation result the user must approve before\n # the action runs. See src/tools/browser_permission_gate.py.\n \"browser_confirm_sensitive\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n # Optional PEM bundle of trusted CAs for provider HTTPS calls.\n # Supports ~ and ${ENV_VAR} expansion.\n \"ca_bundle\": \"\",\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n # Show first-class delegated sub-agents in the chat right sidebar.\n # Default on for v1, but keep a kill switch while the lifecycle API settles.\n \"subagents_sidebar\": True,\n },\n # macOS / desktop app behaviour.\n \"desktop\": {\n # When true, the desktop app auto-starts the messaging gateway in the\n # background on launch (so platforms like Telegram/Discord can reach\n # Spark while the app is open) and stops it on quit. Set false to opt\n # out — the app then never spawns its own gateway and leaves any\n # user-managed external gateway untouched.\n \"gateway_autostart\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"workflow_in_gateway\": False,\n \"workflow_interval_seconds\": 10,\n \"notify_on_changes\": False,\n \"wake_creator_on_changes\": False,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 28,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (OpenRouter / Anthropic / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override (hosted managed gateways are disabled)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for derived vendor hosts (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for derived vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit hosted tool-gateway access token (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\n# Advisory per-platform enable/disable toggles persisted by the Messaging\n# settings UI (see gateway/platform_fields.py). Registered here so\n# reload_env() tracks them (sync + removal). WHATSAPP_ENABLED,\n# WEBHOOK_ENABLED, and API_SERVER_ENABLED are declared individually above.\nfor _flag, _platform_name in (\n (\"TELEGRAM_ENABLED\", \"Telegram\"),\n (\"DISCORD_ENABLED\", \"Discord\"),\n (\"SLACK_ENABLED\", \"Slack\"),\n (\"MATTERMOST_ENABLED\", \"Mattermost\"),\n (\"MATRIX_ENABLED\", \"Matrix\"),\n (\"SIGNAL_ENABLED\", \"Signal\"),\n (\"BLUEBUBBLES_ENABLED\", \"BlueBubbles (iMessage)\"),\n (\"HOMEASSISTANT_ENABLED\", \"Home Assistant\"),\n (\"EMAIL_ENABLED\", \"Email\"),\n (\"SMS_ENABLED\", \"SMS\"),\n (\"DINGTALK_ENABLED\", \"DingTalk\"),\n (\"FEISHU_ENABLED\", \"Feishu/Lark\"),\n (\"WECOM_ENABLED\", \"WeCom\"),\n (\"WECOM_CALLBACK_ENABLED\", \"WeCom Callback\"),\n (\"WEIXIN_ENABLED\", \"WeChat Official Account\"),\n (\"QQBOT_ENABLED\", \"QQ Bot\"),\n):\n OPTIONAL_ENV_VARS.setdefault(\n _flag,\n {\n \"description\": (\n f\"Enable/disable the {_platform_name} messaging platform \"\n \"(true/false). Auto-managed when credentials are saved in \"\n \"Messaging > Settings.\"\n ),\n \"prompt\": f\"Enable {_platform_name} (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n )\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, (dict, list)) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"url\",\n \"api\",\n \"api_key\",\n \"key_env\",\n \"api_mode\",\n \"transport\",\n \"default_model\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n_VALID_PROVIDER_TRANSPORTS = {\n \"openai_chat\",\n \"chat_completions\",\n \"anthropic_messages\",\n \"codex_responses\",\n \"responses\",\n}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── providers must be a keyed dict of OpenAI-compatible endpoints ─────\n providers_cfg = config.get(\"providers\")\n if providers_cfg is not None:\n if not isinstance(providers_cfg, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"providers should be a dict keyed by provider id, \"\n f\"got {type(providers_cfg).__name__}\",\n \"Change to:\\n\"\n \" providers:\\n\"\n \" local-llm:\\n\"\n \" api: http://localhost:11434/v1\\n\"\n \" default_model: llama3\",\n )\n )\n else:\n for provider_key, entry in providers_cfg.items():\n label = f\"providers.{provider_key}\"\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label} is not a dict (got {type(entry).__name__})\",\n \"Each providers entry should contain at minimum an \"\n \"api/base_url and optional models.\",\n )\n )\n continue\n api_url = (\n entry.get(\"api\") or entry.get(\"url\") or entry.get(\"base_url\") or \"\"\n )\n if not isinstance(api_url, str) or not api_url.strip():\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label} is missing an api/url/base_url endpoint\",\n \"Add an OpenAI-compatible endpoint, e.g. api: http://localhost:11434/v1\",\n )\n )\n key_env = entry.get(\"key_env\")\n if key_env is not None and (\n not isinstance(key_env, str)\n or not _ENV_VAR_NAME_RE.match(key_env.strip())\n ):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.key_env is not a valid environment variable name\",\n \"Use only letters, numbers, and underscores, starting with a letter or underscore.\",\n )\n )\n transport = entry.get(\"transport\", entry.get(\"api_mode\"))\n if transport is not None:\n transport_value = str(transport or \"\").strip().lower()\n if transport_value and transport_value not in _VALID_PROVIDER_TRANSPORTS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.transport has unsupported value '{transport}'\",\n \"Use openai_chat, chat_completions, \"\n \"anthropic_messages, or codex_responses.\",\n )\n )\n models = entry.get(\"models\")\n if models is not None:\n valid_models = isinstance(models, list) and all(\n isinstance(m, str) and m.strip() for m in models\n )\n valid_model_meta = isinstance(models, dict)\n if not (valid_models or valid_model_meta):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.models should be a list of model ids\",\n \"Example: models: [llama3, qwen3-coder]\",\n )\n )\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"workflow_in_gateway\": False,\n \"workflow_interval_seconds\": 10,\n \"notify_on_changes\": False,\n \"wake_creator_on_changes\": False,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # -- Version 26 -> 27: add Kanban notification/wake workflow defaults --\n if current_ver < 27:\n config = read_raw_config()\n kanban = config.setdefault(\"kanban\", {})\n changed = False\n if isinstance(kanban, dict):\n for key, value in {\n \"workflow_in_gateway\": False,\n \"workflow_interval_seconds\": 10,\n \"notify_on_changes\": False,\n \"wake_creator_on_changes\": False,\n }.items():\n if key not in kanban:\n kanban[key] = value\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added kanban workflow notification/wake defaults\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n # ── Version 22 → 23: add display.preview_browser_backend (WebUI browser pane) ──\n if current_ver < 23:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"preview_browser_backend\" not in display:\n display[\"preview_browser_backend\"] = \"auto\"\n config[\"display\"] = display\n save_config(config)\n results[\"config_added\"].append(\"display.preview_browser_backend=auto (default)\")\n if not quiet:\n print(\" ✓ Added display.preview_browser_backend=auto\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "856ae70f618ede20cb2406fefc5f3478c8c1e5fd": {"t": 1785768513.8767738, "n": 152423, "h": "7c37cfea622fd071", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file) as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n # User-facing name for the agent, set during onboarding. Surfaced in the\n # system prompt so the agent knows what to call itself.\n \"name\": \"Spark\",\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow browser/CDP access to private/internal IPs beyond loopback previews\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n # WebUI Preview-tab streamed browser backend:\n # \"auto\" -> use agent-browser when available, else fall back to Playwright\n # \"agent-browser\" -> always agent-browser (shares the agent's browser session)\n # \"playwright\" -> always the Playwright StreamedBrowserSession\n \"preview_browser_backend\": \"auto\",\n \"chat_word_wrap\": False, # WebUI: default word wrap for chat code blocks and markdown tables\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Anthropic, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n \"onboarding_mode\": \"\", # \"recommended\" | \"minimal\" | \"none\" — set during setup\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n # When True, the agent browser pauses before sensitive actions\n # (submitting payments, sending messages, logging into a NEW domain)\n # and returns a needs_confirmation result the user must approve before\n # the action runs. See src/tools/browser_permission_gate.py.\n \"browser_confirm_sensitive\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n # Optional PEM bundle of trusted CAs for provider HTTPS calls.\n # Supports ~ and ${ENV_VAR} expansion.\n \"ca_bundle\": \"\",\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n # Show first-class delegated sub-agents in the chat right sidebar.\n # Default on for v1, but keep a kill switch while the lifecycle API settles.\n \"subagents_sidebar\": True,\n },\n # macOS / desktop app behaviour.\n \"desktop\": {\n # When true, the desktop app auto-starts the messaging gateway in the\n # background on launch (so platforms like Telegram/Discord can reach\n # Spark while the app is open) and stops it on quit. Set false to opt\n # out — the app then never spawns its own gateway and leaves any\n # user-managed external gateway untouched.\n \"gateway_autostart\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"workflow_in_gateway\": False,\n \"workflow_interval_seconds\": 10,\n \"notify_on_changes\": False,\n \"wake_creator_on_changes\": False,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 27,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (OpenRouter / Anthropic / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override (hosted managed gateways are disabled)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for derived vendor hosts (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for derived vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit hosted tool-gateway access token (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\n# Advisory per-platform enable/disable toggles persisted by the Messaging\n# settings UI (see gateway/platform_fields.py). Registered here so\n# reload_env() tracks them (sync + removal). WHATSAPP_ENABLED,\n# WEBHOOK_ENABLED, and API_SERVER_ENABLED are declared individually above.\nfor _flag, _platform_name in (\n (\"TELEGRAM_ENABLED\", \"Telegram\"),\n (\"DISCORD_ENABLED\", \"Discord\"),\n (\"SLACK_ENABLED\", \"Slack\"),\n (\"MATTERMOST_ENABLED\", \"Mattermost\"),\n (\"MATRIX_ENABLED\", \"Matrix\"),\n (\"SIGNAL_ENABLED\", \"Signal\"),\n (\"BLUEBUBBLES_ENABLED\", \"BlueBubbles (iMessage)\"),\n (\"HOMEASSISTANT_ENABLED\", \"Home Assistant\"),\n (\"EMAIL_ENABLED\", \"Email\"),\n (\"SMS_ENABLED\", \"SMS\"),\n (\"DINGTALK_ENABLED\", \"DingTalk\"),\n (\"FEISHU_ENABLED\", \"Feishu/Lark\"),\n (\"WECOM_ENABLED\", \"WeCom\"),\n (\"WECOM_CALLBACK_ENABLED\", \"WeCom Callback\"),\n (\"WEIXIN_ENABLED\", \"WeChat Official Account\"),\n (\"QQBOT_ENABLED\", \"QQ Bot\"),\n):\n OPTIONAL_ENV_VARS.setdefault(\n _flag,\n {\n \"description\": (\n f\"Enable/disable the {_platform_name} messaging platform \"\n \"(true/false). Auto-managed when credentials are saved in \"\n \"Messaging > Settings.\"\n ),\n \"prompt\": f\"Enable {_platform_name} (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n )\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, (dict, list)) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"url\",\n \"api\",\n \"api_key\",\n \"key_env\",\n \"api_mode\",\n \"transport\",\n \"default_model\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n_VALID_PROVIDER_TRANSPORTS = {\n \"openai_chat\",\n \"chat_completions\",\n \"anthropic_messages\",\n \"codex_responses\",\n \"responses\",\n}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── providers must be a keyed dict of OpenAI-compatible endpoints ─────\n providers_cfg = config.get(\"providers\")\n if providers_cfg is not None:\n if not isinstance(providers_cfg, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"providers should be a dict keyed by provider id, \"\n f\"got {type(providers_cfg).__name__}\",\n \"Change to:\\n\"\n \" providers:\\n\"\n \" local-llm:\\n\"\n \" api: http://localhost:11434/v1\\n\"\n \" default_model: llama3\",\n )\n )\n else:\n for provider_key, entry in providers_cfg.items():\n label = f\"providers.{provider_key}\"\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label} is not a dict (got {type(entry).__name__})\",\n \"Each providers entry should contain at minimum an \"\n \"api/base_url and optional models.\",\n )\n )\n continue\n api_url = (\n entry.get(\"api\") or entry.get(\"url\") or entry.get(\"base_url\") or \"\"\n )\n if not isinstance(api_url, str) or not api_url.strip():\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label} is missing an api/url/base_url endpoint\",\n \"Add an OpenAI-compatible endpoint, e.g. api: http://localhost:11434/v1\",\n )\n )\n key_env = entry.get(\"key_env\")\n if key_env is not None and (\n not isinstance(key_env, str)\n or not _ENV_VAR_NAME_RE.match(key_env.strip())\n ):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.key_env is not a valid environment variable name\",\n \"Use only letters, numbers, and underscores, starting with a letter or underscore.\",\n )\n )\n transport = entry.get(\"transport\", entry.get(\"api_mode\"))\n if transport is not None:\n transport_value = str(transport or \"\").strip().lower()\n if transport_value and transport_value not in _VALID_PROVIDER_TRANSPORTS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.transport has unsupported value '{transport}'\",\n \"Use openai_chat, chat_completions, \"\n \"anthropic_messages, or codex_responses.\",\n )\n )\n models = entry.get(\"models\")\n if models is not None:\n valid_models = isinstance(models, list) and all(\n isinstance(m, str) and m.strip() for m in models\n )\n valid_model_meta = isinstance(models, dict)\n if not (valid_models or valid_model_meta):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.models should be a list of model ids\",\n \"Example: models: [llama3, qwen3-coder]\",\n )\n )\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"workflow_in_gateway\": False,\n \"workflow_interval_seconds\": 10,\n \"notify_on_changes\": False,\n \"wake_creator_on_changes\": False,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # -- Version 26 -> 27: add Kanban notification/wake workflow defaults --\n if current_ver < 27:\n config = read_raw_config()\n kanban = config.setdefault(\"kanban\", {})\n changed = False\n if isinstance(kanban, dict):\n for key, value in {\n \"workflow_in_gateway\": False,\n \"workflow_interval_seconds\": 10,\n \"notify_on_changes\": False,\n \"wake_creator_on_changes\": False,\n }.items():\n if key not in kanban:\n kanban[key] = value\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added kanban workflow notification/wake defaults\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n # ── Version 22 → 23: add display.preview_browser_backend (WebUI browser pane) ──\n if current_ver < 23:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"preview_browser_backend\" not in display:\n display[\"preview_browser_backend\"] = \"auto\"\n config[\"display\"] = display\n save_config(config)\n results[\"config_added\"].append(\"display.preview_browser_backend=auto (default)\")\n if not quiet:\n print(\" ✓ Added display.preview_browser_backend=auto\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "7bea04dc69e7e7c7a0132a5172817a4c28543448": {"t": 1785768517.8488026, "n": 150989, "h": "9a6c1be4949feb7a", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file) as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n # User-facing name for the agent, set during onboarding. Surfaced in the\n # system prompt so the agent knows what to call itself.\n \"name\": \"Spark\",\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow browser/CDP access to private/internal IPs beyond loopback previews\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n # WebUI Preview-tab streamed browser backend:\n # \"auto\" -> use agent-browser when available, else fall back to Playwright\n # \"agent-browser\" -> always agent-browser (shares the agent's browser session)\n # \"playwright\" -> always the Playwright StreamedBrowserSession\n \"preview_browser_backend\": \"auto\",\n \"chat_word_wrap\": False, # WebUI: default word wrap for chat code blocks and markdown tables\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Anthropic, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n \"onboarding_mode\": \"\", # \"recommended\" | \"minimal\" | \"none\" — set during setup\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n # When True, the agent browser pauses before sensitive actions\n # (submitting payments, sending messages, logging into a NEW domain)\n # and returns a needs_confirmation result the user must approve before\n # the action runs. See src/tools/browser_permission_gate.py.\n \"browser_confirm_sensitive\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n # Optional PEM bundle of trusted CAs for provider HTTPS calls.\n # Supports ~ and ${ENV_VAR} expansion.\n \"ca_bundle\": \"\",\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n # Show first-class delegated sub-agents in the chat right sidebar.\n # Default on for v1, but keep a kill switch while the lifecycle API settles.\n \"subagents_sidebar\": True,\n },\n # macOS / desktop app behaviour.\n \"desktop\": {\n # When true, the desktop app auto-starts the messaging gateway in the\n # background on launch (so platforms like Telegram/Discord can reach\n # Spark while the app is open) and stops it on quit. Set false to opt\n # out — the app then never spawns its own gateway and leaves any\n # user-managed external gateway untouched.\n \"gateway_autostart\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"workflow_in_gateway\": False,\n \"workflow_interval_seconds\": 10,\n \"notify_on_changes\": False,\n \"wake_creator_on_changes\": False,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 27,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (OpenRouter / Anthropic / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override (hosted managed gateways are disabled)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for derived vendor hosts (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for derived vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit hosted tool-gateway access token (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, (dict, list)) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"url\",\n \"api\",\n \"api_key\",\n \"key_env\",\n \"api_mode\",\n \"transport\",\n \"default_model\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n_VALID_PROVIDER_TRANSPORTS = {\n \"openai_chat\",\n \"chat_completions\",\n \"anthropic_messages\",\n \"codex_responses\",\n \"responses\",\n}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── providers must be a keyed dict of OpenAI-compatible endpoints ─────\n providers_cfg = config.get(\"providers\")\n if providers_cfg is not None:\n if not isinstance(providers_cfg, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"providers should be a dict keyed by provider id, \"\n f\"got {type(providers_cfg).__name__}\",\n \"Change to:\\n\"\n \" providers:\\n\"\n \" local-llm:\\n\"\n \" api: http://localhost:11434/v1\\n\"\n \" default_model: llama3\",\n )\n )\n else:\n for provider_key, entry in providers_cfg.items():\n label = f\"providers.{provider_key}\"\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label} is not a dict (got {type(entry).__name__})\",\n \"Each providers entry should contain at minimum an \"\n \"api/base_url and optional models.\",\n )\n )\n continue\n api_url = (\n entry.get(\"api\") or entry.get(\"url\") or entry.get(\"base_url\") or \"\"\n )\n if not isinstance(api_url, str) or not api_url.strip():\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label} is missing an api/url/base_url endpoint\",\n \"Add an OpenAI-compatible endpoint, e.g. api: http://localhost:11434/v1\",\n )\n )\n key_env = entry.get(\"key_env\")\n if key_env is not None and (\n not isinstance(key_env, str)\n or not _ENV_VAR_NAME_RE.match(key_env.strip())\n ):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.key_env is not a valid environment variable name\",\n \"Use only letters, numbers, and underscores, starting with a letter or underscore.\",\n )\n )\n transport = entry.get(\"transport\", entry.get(\"api_mode\"))\n if transport is not None:\n transport_value = str(transport or \"\").strip().lower()\n if transport_value and transport_value not in _VALID_PROVIDER_TRANSPORTS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.transport has unsupported value '{transport}'\",\n \"Use openai_chat, chat_completions, \"\n \"anthropic_messages, or codex_responses.\",\n )\n )\n models = entry.get(\"models\")\n if models is not None:\n valid_models = isinstance(models, list) and all(\n isinstance(m, str) and m.strip() for m in models\n )\n valid_model_meta = isinstance(models, dict)\n if not (valid_models or valid_model_meta):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.models should be a list of model ids\",\n \"Example: models: [llama3, qwen3-coder]\",\n )\n )\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"workflow_in_gateway\": False,\n \"workflow_interval_seconds\": 10,\n \"notify_on_changes\": False,\n \"wake_creator_on_changes\": False,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # -- Version 26 -> 27: add Kanban notification/wake workflow defaults --\n if current_ver < 27:\n config = read_raw_config()\n kanban = config.setdefault(\"kanban\", {})\n changed = False\n if isinstance(kanban, dict):\n for key, value in {\n \"workflow_in_gateway\": False,\n \"workflow_interval_seconds\": 10,\n \"notify_on_changes\": False,\n \"wake_creator_on_changes\": False,\n }.items():\n if key not in kanban:\n kanban[key] = value\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added kanban workflow notification/wake defaults\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n # ── Version 22 → 23: add display.preview_browser_backend (WebUI browser pane) ──\n if current_ver < 23:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"preview_browser_backend\" not in display:\n display[\"preview_browser_backend\"] = \"auto\"\n config[\"display\"] = display\n save_config(config)\n results[\"config_added\"].append(\"display.preview_browser_backend=auto (default)\")\n if not quiet:\n print(\" ✓ Added display.preview_browser_backend=auto\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "daf7d88c5d24bab5d232a2e679e19efaa0a6cac6": {"t": 1785768521.4791903, "n": 149882, "h": "ce456bc97d123e12", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file) as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n # User-facing name for the agent, set during onboarding. Surfaced in the\n # system prompt so the agent knows what to call itself.\n \"name\": \"Spark\",\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow browser/CDP access to private/internal IPs beyond loopback previews\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n # WebUI Preview-tab streamed browser backend:\n # \"auto\" -> use agent-browser when available, else fall back to Playwright\n # \"agent-browser\" -> always agent-browser (shares the agent's browser session)\n # \"playwright\" -> always the Playwright StreamedBrowserSession\n \"preview_browser_backend\": \"auto\",\n \"chat_word_wrap\": False, # WebUI: default word wrap for chat code blocks and markdown tables\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Anthropic, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n \"onboarding_mode\": \"\", # \"recommended\" | \"minimal\" | \"none\" — set during setup\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n # When True, the agent browser pauses before sensitive actions\n # (submitting payments, sending messages, logging into a NEW domain)\n # and returns a needs_confirmation result the user must approve before\n # the action runs. See src/tools/browser_permission_gate.py.\n \"browser_confirm_sensitive\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n # Optional PEM bundle of trusted CAs for provider HTTPS calls.\n # Supports ~ and ${ENV_VAR} expansion.\n \"ca_bundle\": \"\",\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n # Show first-class delegated sub-agents in the chat right sidebar.\n # Default on for v1, but keep a kill switch while the lifecycle API settles.\n \"subagents_sidebar\": True,\n },\n # macOS / desktop app behaviour.\n \"desktop\": {\n # When true, the desktop app auto-starts the messaging gateway in the\n # background on launch (so platforms like Telegram/Discord can reach\n # Spark while the app is open) and stops it on quit. Set false to opt\n # out — the app then never spawns its own gateway and leaves any\n # user-managed external gateway untouched.\n \"gateway_autostart\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 27,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (OpenRouter / Anthropic / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override (hosted managed gateways are disabled)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for derived vendor hosts (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for derived vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit hosted tool-gateway access token (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, (dict, list)) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"url\",\n \"api\",\n \"api_key\",\n \"key_env\",\n \"api_mode\",\n \"transport\",\n \"default_model\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n_VALID_PROVIDER_TRANSPORTS = {\n \"openai_chat\",\n \"chat_completions\",\n \"anthropic_messages\",\n \"codex_responses\",\n \"responses\",\n}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── providers must be a keyed dict of OpenAI-compatible endpoints ─────\n providers_cfg = config.get(\"providers\")\n if providers_cfg is not None:\n if not isinstance(providers_cfg, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"providers should be a dict keyed by provider id, \"\n f\"got {type(providers_cfg).__name__}\",\n \"Change to:\\n\"\n \" providers:\\n\"\n \" local-llm:\\n\"\n \" api: http://localhost:11434/v1\\n\"\n \" default_model: llama3\",\n )\n )\n else:\n for provider_key, entry in providers_cfg.items():\n label = f\"providers.{provider_key}\"\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label} is not a dict (got {type(entry).__name__})\",\n \"Each providers entry should contain at minimum an \"\n \"api/base_url and optional models.\",\n )\n )\n continue\n api_url = (\n entry.get(\"api\") or entry.get(\"url\") or entry.get(\"base_url\") or \"\"\n )\n if not isinstance(api_url, str) or not api_url.strip():\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label} is missing an api/url/base_url endpoint\",\n \"Add an OpenAI-compatible endpoint, e.g. api: http://localhost:11434/v1\",\n )\n )\n key_env = entry.get(\"key_env\")\n if key_env is not None and (\n not isinstance(key_env, str)\n or not _ENV_VAR_NAME_RE.match(key_env.strip())\n ):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.key_env is not a valid environment variable name\",\n \"Use only letters, numbers, and underscores, starting with a letter or underscore.\",\n )\n )\n transport = entry.get(\"transport\", entry.get(\"api_mode\"))\n if transport is not None:\n transport_value = str(transport or \"\").strip().lower()\n if transport_value and transport_value not in _VALID_PROVIDER_TRANSPORTS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.transport has unsupported value '{transport}'\",\n \"Use openai_chat, chat_completions, \"\n \"anthropic_messages, or codex_responses.\",\n )\n )\n models = entry.get(\"models\")\n if models is not None:\n valid_models = isinstance(models, list) and all(\n isinstance(m, str) and m.strip() for m in models\n )\n valid_model_meta = isinstance(models, dict)\n if not (valid_models or valid_model_meta):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.models should be a list of model ids\",\n \"Example: models: [llama3, qwen3-coder]\",\n )\n )\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n # ── Version 22 → 23: add display.preview_browser_backend (WebUI browser pane) ──\n if current_ver < 23:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"preview_browser_backend\" not in display:\n display[\"preview_browser_backend\"] = \"auto\"\n config[\"display\"] = display\n save_config(config)\n results[\"config_added\"].append(\"display.preview_browser_backend=auto (default)\")\n if not quiet:\n print(\" ✓ Added display.preview_browser_backend=auto\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "6ad4b97ba829f05993d76b7d980c2b8dd2674dd9": {"t": 1785768524.6474705, "n": 149780, "h": "dfb0d93e606b29b2", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file) as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n # User-facing name for the agent, set during onboarding. Surfaced in the\n # system prompt so the agent knows what to call itself.\n \"name\": \"Spark\",\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow browser/CDP access to private/internal IPs beyond loopback previews\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n # WebUI Preview-tab streamed browser backend:\n # \"auto\" -> use agent-browser when available, else fall back to Playwright\n # \"agent-browser\" -> always agent-browser (shares the agent's browser session)\n # \"playwright\" -> always the Playwright StreamedBrowserSession\n \"preview_browser_backend\": \"auto\",\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Anthropic, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n \"onboarding_mode\": \"\", # \"recommended\" | \"minimal\" | \"none\" — set during setup\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n # When True, the agent browser pauses before sensitive actions\n # (submitting payments, sending messages, logging into a NEW domain)\n # and returns a needs_confirmation result the user must approve before\n # the action runs. See src/tools/browser_permission_gate.py.\n \"browser_confirm_sensitive\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n # Optional PEM bundle of trusted CAs for provider HTTPS calls.\n # Supports ~ and ${ENV_VAR} expansion.\n \"ca_bundle\": \"\",\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n # Show first-class delegated sub-agents in the chat right sidebar.\n # Default on for v1, but keep a kill switch while the lifecycle API settles.\n \"subagents_sidebar\": True,\n },\n # macOS / desktop app behaviour.\n \"desktop\": {\n # When true, the desktop app auto-starts the messaging gateway in the\n # background on launch (so platforms like Telegram/Discord can reach\n # Spark while the app is open) and stops it on quit. Set false to opt\n # out — the app then never spawns its own gateway and leaves any\n # user-managed external gateway untouched.\n \"gateway_autostart\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 27,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (OpenRouter / Anthropic / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override (hosted managed gateways are disabled)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for derived vendor hosts (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for derived vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit hosted tool-gateway access token (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, (dict, list)) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"url\",\n \"api\",\n \"api_key\",\n \"key_env\",\n \"api_mode\",\n \"transport\",\n \"default_model\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n_VALID_PROVIDER_TRANSPORTS = {\n \"openai_chat\",\n \"chat_completions\",\n \"anthropic_messages\",\n \"codex_responses\",\n \"responses\",\n}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── providers must be a keyed dict of OpenAI-compatible endpoints ─────\n providers_cfg = config.get(\"providers\")\n if providers_cfg is not None:\n if not isinstance(providers_cfg, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"providers should be a dict keyed by provider id, \"\n f\"got {type(providers_cfg).__name__}\",\n \"Change to:\\n\"\n \" providers:\\n\"\n \" local-llm:\\n\"\n \" api: http://localhost:11434/v1\\n\"\n \" default_model: llama3\",\n )\n )\n else:\n for provider_key, entry in providers_cfg.items():\n label = f\"providers.{provider_key}\"\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label} is not a dict (got {type(entry).__name__})\",\n \"Each providers entry should contain at minimum an \"\n \"api/base_url and optional models.\",\n )\n )\n continue\n api_url = (\n entry.get(\"api\") or entry.get(\"url\") or entry.get(\"base_url\") or \"\"\n )\n if not isinstance(api_url, str) or not api_url.strip():\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label} is missing an api/url/base_url endpoint\",\n \"Add an OpenAI-compatible endpoint, e.g. api: http://localhost:11434/v1\",\n )\n )\n key_env = entry.get(\"key_env\")\n if key_env is not None and (\n not isinstance(key_env, str)\n or not _ENV_VAR_NAME_RE.match(key_env.strip())\n ):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.key_env is not a valid environment variable name\",\n \"Use only letters, numbers, and underscores, starting with a letter or underscore.\",\n )\n )\n transport = entry.get(\"transport\", entry.get(\"api_mode\"))\n if transport is not None:\n transport_value = str(transport or \"\").strip().lower()\n if transport_value and transport_value not in _VALID_PROVIDER_TRANSPORTS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.transport has unsupported value '{transport}'\",\n \"Use openai_chat, chat_completions, \"\n \"anthropic_messages, or codex_responses.\",\n )\n )\n models = entry.get(\"models\")\n if models is not None:\n valid_models = isinstance(models, list) and all(\n isinstance(m, str) and m.strip() for m in models\n )\n valid_model_meta = isinstance(models, dict)\n if not (valid_models or valid_model_meta):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.models should be a list of model ids\",\n \"Example: models: [llama3, qwen3-coder]\",\n )\n )\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n # ── Version 22 → 23: add display.preview_browser_backend (WebUI browser pane) ──\n if current_ver < 23:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"preview_browser_backend\" not in display:\n display[\"preview_browser_backend\"] = \"auto\"\n config[\"display\"] = display\n save_config(config)\n results[\"config_added\"].append(\"display.preview_browser_backend=auto (default)\")\n if not quiet:\n print(\" ✓ Added display.preview_browser_backend=auto\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "f0274b25284c8c599b1ac75ecadc3162688c66b7": {"t": 1785768527.086993, "n": 149637, "h": "2d95b30061e709bc", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file) as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n # User-facing name for the agent, set during onboarding. Surfaced in the\n # system prompt so the agent knows what to call itself.\n \"name\": \"Spark\",\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow browser/CDP access to private/internal IPs beyond loopback previews\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n # WebUI Preview-tab streamed browser backend:\n # \"auto\" -> use agent-browser when available, else fall back to Playwright\n # \"agent-browser\" -> always agent-browser (shares the agent's browser session)\n # \"playwright\" -> always the Playwright StreamedBrowserSession\n \"preview_browser_backend\": \"auto\",\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Anthropic, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n \"onboarding_mode\": \"\", # \"recommended\" | \"minimal\" | \"none\" — set during setup\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n # When True, the agent browser pauses before sensitive actions\n # (submitting payments, sending messages, logging into a NEW domain)\n # and returns a needs_confirmation result the user must approve before\n # the action runs. See src/tools/browser_permission_gate.py.\n \"browser_confirm_sensitive\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n # Show first-class delegated sub-agents in the chat right sidebar.\n # Default on for v1, but keep a kill switch while the lifecycle API settles.\n \"subagents_sidebar\": True,\n },\n # macOS / desktop app behaviour.\n \"desktop\": {\n # When true, the desktop app auto-starts the messaging gateway in the\n # background on launch (so platforms like Telegram/Discord can reach\n # Spark while the app is open) and stops it on quit. Set false to opt\n # out — the app then never spawns its own gateway and leaves any\n # user-managed external gateway untouched.\n \"gateway_autostart\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 26,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (OpenRouter / Anthropic / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override (hosted managed gateways are disabled)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for derived vendor hosts (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for derived vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit hosted tool-gateway access token (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, (dict, list)) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"url\",\n \"api\",\n \"api_key\",\n \"key_env\",\n \"api_mode\",\n \"transport\",\n \"default_model\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n_VALID_PROVIDER_TRANSPORTS = {\n \"openai_chat\",\n \"chat_completions\",\n \"anthropic_messages\",\n \"codex_responses\",\n \"responses\",\n}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── providers must be a keyed dict of OpenAI-compatible endpoints ─────\n providers_cfg = config.get(\"providers\")\n if providers_cfg is not None:\n if not isinstance(providers_cfg, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"providers should be a dict keyed by provider id, \"\n f\"got {type(providers_cfg).__name__}\",\n \"Change to:\\n\"\n \" providers:\\n\"\n \" local-llm:\\n\"\n \" api: http://localhost:11434/v1\\n\"\n \" default_model: llama3\",\n )\n )\n else:\n for provider_key, entry in providers_cfg.items():\n label = f\"providers.{provider_key}\"\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label} is not a dict (got {type(entry).__name__})\",\n \"Each providers entry should contain at minimum an \"\n \"api/base_url and optional models.\",\n )\n )\n continue\n api_url = (\n entry.get(\"api\") or entry.get(\"url\") or entry.get(\"base_url\") or \"\"\n )\n if not isinstance(api_url, str) or not api_url.strip():\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label} is missing an api/url/base_url endpoint\",\n \"Add an OpenAI-compatible endpoint, e.g. api: http://localhost:11434/v1\",\n )\n )\n key_env = entry.get(\"key_env\")\n if key_env is not None and (\n not isinstance(key_env, str)\n or not _ENV_VAR_NAME_RE.match(key_env.strip())\n ):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.key_env is not a valid environment variable name\",\n \"Use only letters, numbers, and underscores, starting with a letter or underscore.\",\n )\n )\n transport = entry.get(\"transport\", entry.get(\"api_mode\"))\n if transport is not None:\n transport_value = str(transport or \"\").strip().lower()\n if transport_value and transport_value not in _VALID_PROVIDER_TRANSPORTS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.transport has unsupported value '{transport}'\",\n \"Use openai_chat, chat_completions, \"\n \"anthropic_messages, or codex_responses.\",\n )\n )\n models = entry.get(\"models\")\n if models is not None:\n valid_models = isinstance(models, list) and all(\n isinstance(m, str) and m.strip() for m in models\n )\n valid_model_meta = isinstance(models, dict)\n if not (valid_models or valid_model_meta):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.models should be a list of model ids\",\n \"Example: models: [llama3, qwen3-coder]\",\n )\n )\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n # ── Version 22 → 23: add display.preview_browser_backend (WebUI browser pane) ──\n if current_ver < 23:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"preview_browser_backend\" not in display:\n display[\"preview_browser_backend\"] = \"auto\"\n config[\"display\"] = display\n save_config(config)\n results[\"config_added\"].append(\"display.preview_browser_backend=auto (default)\")\n if not quiet:\n print(\" ✓ Added display.preview_browser_backend=auto\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "b072e7cb254f5b04df5394cf5cbcffa2baa5afb4": {"t": 1785768529.5995886, "n": 145601, "h": "adb695950d332c24", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file) as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n # User-facing name for the agent, set during onboarding. Surfaced in the\n # system prompt so the agent knows what to call itself.\n \"name\": \"Spark\",\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow browser/CDP access to private/internal IPs beyond loopback previews\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n # WebUI Preview-tab streamed browser backend:\n # \"auto\" -> use agent-browser when available, else fall back to Playwright\n # \"agent-browser\" -> always agent-browser (shares the agent's browser session)\n # \"playwright\" -> always the Playwright StreamedBrowserSession\n \"preview_browser_backend\": \"auto\",\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Anthropic, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n \"onboarding_mode\": \"\", # \"recommended\" | \"minimal\" | \"none\" — set during setup\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n # When True, the agent browser pauses before sensitive actions\n # (submitting payments, sending messages, logging into a NEW domain)\n # and returns a needs_confirmation result the user must approve before\n # the action runs. See src/tools/browser_permission_gate.py.\n \"browser_confirm_sensitive\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n # Show first-class delegated sub-agents in the chat right sidebar.\n # Default on for v1, but keep a kill switch while the lifecycle API settles.\n \"subagents_sidebar\": True,\n },\n # macOS / desktop app behaviour.\n \"desktop\": {\n # When true, the desktop app auto-starts the messaging gateway in the\n # background on launch (so platforms like Telegram/Discord can reach\n # Spark while the app is open) and stops it on quit. Set false to opt\n # out — the app then never spawns its own gateway and leaves any\n # user-managed external gateway untouched.\n \"gateway_autostart\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 26,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (OpenRouter / Anthropic / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override (hosted managed gateways are disabled)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for derived vendor hosts (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for derived vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit hosted tool-gateway access token (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n # ── Version 22 → 23: add display.preview_browser_backend (WebUI browser pane) ──\n if current_ver < 23:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"preview_browser_backend\" not in display:\n display[\"preview_browser_backend\"] = \"auto\"\n config[\"display\"] = display\n save_config(config)\n results[\"config_added\"].append(\"display.preview_browser_backend=auto (default)\")\n if not quiet:\n print(\" ✓ Added display.preview_browser_backend=auto\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "af6f3af8fb6ff5f4fa40d1e43d17ca52349f560b": {"t": 1785768531.451461, "n": 145606, "h": "192195abbef9bcf9", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n # User-facing name for the agent, set during onboarding. Surfaced in the\n # system prompt so the agent knows what to call itself.\n \"name\": \"Spark\",\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow browser/CDP access to private/internal IPs beyond loopback previews\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n # WebUI Preview-tab streamed browser backend:\n # \"auto\" -> use agent-browser when available, else fall back to Playwright\n # \"agent-browser\" -> always agent-browser (shares the agent's browser session)\n # \"playwright\" -> always the Playwright StreamedBrowserSession\n \"preview_browser_backend\": \"auto\",\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Anthropic, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n \"onboarding_mode\": \"\", # \"recommended\" | \"minimal\" | \"none\" — set during setup\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n # When True, the agent browser pauses before sensitive actions\n # (submitting payments, sending messages, logging into a NEW domain)\n # and returns a needs_confirmation result the user must approve before\n # the action runs. See src/tools/browser_permission_gate.py.\n \"browser_confirm_sensitive\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n # Show first-class delegated sub-agents in the chat right sidebar.\n # Default on for v1, but keep a kill switch while the lifecycle API settles.\n \"subagents_sidebar\": True,\n },\n # macOS / desktop app behaviour.\n \"desktop\": {\n # When true, the desktop app auto-starts the messaging gateway in the\n # background on launch (so platforms like Telegram/Discord can reach\n # Spark while the app is open) and stops it on quit. Set false to opt\n # out — the app then never spawns its own gateway and leaves any\n # user-managed external gateway untouched.\n \"gateway_autostart\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 26,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (OpenRouter / Anthropic / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override (hosted managed gateways are disabled)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for derived vendor hosts (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for derived vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit hosted tool-gateway access token (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n # ── Version 22 → 23: add display.preview_browser_backend (WebUI browser pane) ──\n if current_ver < 23:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"preview_browser_backend\" not in display:\n display[\"preview_browser_backend\"] = \"auto\"\n config[\"display\"] = display\n save_config(config)\n results[\"config_added\"].append(\"display.preview_browser_backend=auto (default)\")\n if not quiet:\n print(\" ✓ Added display.preview_browser_backend=auto\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "acb14a38351990afa1ecf8ebf070cff6e412c7db": {"t": 1785768534.123163, "n": 145604, "h": "aee1ef747b944ece", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n # User-facing name for the agent, set during onboarding. Surfaced in the\n # system prompt so the agent knows what to call itself.\n \"name\": \"Spark\",\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n # WebUI Preview-tab streamed browser backend:\n # \"auto\" -> use agent-browser when available, else fall back to Playwright\n # \"agent-browser\" -> always agent-browser (shares the agent's browser session)\n # \"playwright\" -> always the Playwright StreamedBrowserSession\n \"preview_browser_backend\": \"auto\",\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Anthropic, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n \"onboarding_mode\": \"\", # \"recommended\" | \"minimal\" | \"none\" — set during setup\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n # When True, the agent browser pauses before sensitive actions\n # (submitting payments, sending messages, logging into a NEW domain)\n # and returns a needs_confirmation result the user must approve before\n # the action runs. See src/tools/browser_permission_gate.py.\n \"browser_confirm_sensitive\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n # Show first-class delegated sub-agents in the chat right sidebar.\n # Default on for v1, but keep a kill switch while the lifecycle API settles.\n \"subagents_sidebar\": True,\n },\n # macOS / desktop app behaviour.\n \"desktop\": {\n # When true, the desktop app auto-starts the messaging gateway in the\n # background on launch (so platforms like Telegram/Discord can reach\n # Spark while the app is open) and stops it on quit. Set false to opt\n # out — the app then never spawns its own gateway and leaves any\n # user-managed external gateway untouched.\n \"gateway_autostart\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 26,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (OpenRouter / Anthropic / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override (hosted managed gateways are disabled)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for derived vendor hosts (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for derived vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit hosted tool-gateway access token (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n # ── Version 22 → 23: add display.preview_browser_backend (WebUI browser pane) ──\n if current_ver < 23:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"preview_browser_backend\" not in display:\n display[\"preview_browser_backend\"] = \"auto\"\n config[\"display\"] = display\n save_config(config)\n results[\"config_added\"].append(\"display.preview_browser_backend=auto (default)\")\n if not quiet:\n print(\" ✓ Added display.preview_browser_backend=auto\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "88ca646ef7d09805e983d669643a6ef8a74750a5": {"t": 1785768536.0959432, "n": 145409, "h": "3839e4e570e7c049", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n # User-facing name for the agent, set during onboarding. Surfaced in the\n # system prompt so the agent knows what to call itself.\n \"name\": \"Spark\",\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n # WebUI Preview-tab streamed browser backend:\n # \"auto\" -> use agent-browser when available, else fall back to Playwright\n # \"agent-browser\" -> always agent-browser (shares the agent's browser session)\n # \"playwright\" -> always the Playwright StreamedBrowserSession\n \"preview_browser_backend\": \"auto\",\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Anthropic, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n \"onboarding_mode\": \"\", # \"recommended\" | \"minimal\" | \"none\" — set during setup\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n # When True, the agent browser pauses before sensitive actions\n # (submitting payments, sending messages, logging into a NEW domain)\n # and returns a needs_confirmation result the user must approve before\n # the action runs. See src/tools/browser_permission_gate.py.\n \"browser_confirm_sensitive\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n },\n # macOS / desktop app behaviour.\n \"desktop\": {\n # When true, the desktop app auto-starts the messaging gateway in the\n # background on launch (so platforms like Telegram/Discord can reach\n # Spark while the app is open) and stops it on quit. Set false to opt\n # out — the app then never spawns its own gateway and leaves any\n # user-managed external gateway untouched.\n \"gateway_autostart\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 25,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (OpenRouter / Anthropic / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override (hosted managed gateways are disabled)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for derived vendor hosts (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for derived vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit hosted tool-gateway access token (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n # ── Version 22 → 23: add display.preview_browser_backend (WebUI browser pane) ──\n if current_ver < 23:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"preview_browser_backend\" not in display:\n display[\"preview_browser_backend\"] = \"auto\"\n config[\"display\"] = display\n save_config(config)\n results[\"config_added\"].append(\"display.preview_browser_backend=auto (default)\")\n if not quiet:\n print(\" ✓ Added display.preview_browser_backend=auto\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "5ca07eb9c472f9e95a90694908d55d5b91b21c08": {"t": 1785768538.4764569, "n": 144956, "h": "f7a8a0397bb108b8", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n # User-facing name for the agent, set during onboarding. Surfaced in the\n # system prompt so the agent knows what to call itself.\n \"name\": \"Spark\",\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n # WebUI Preview-tab streamed browser backend:\n # \"auto\" -> use agent-browser when available, else fall back to Playwright\n # \"agent-browser\" -> always agent-browser (shares the agent's browser session)\n # \"playwright\" -> always the Playwright StreamedBrowserSession\n \"preview_browser_backend\": \"auto\",\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Anthropic, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n \"onboarding_mode\": \"\", # \"recommended\" | \"minimal\" | \"none\" — set during setup\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n # When True, the agent browser pauses before sensitive actions\n # (submitting payments, sending messages, logging into a NEW domain)\n # and returns a needs_confirmation result the user must approve before\n # the action runs. See src/tools/browser_permission_gate.py.\n \"browser_confirm_sensitive\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 24,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (OpenRouter / Anthropic / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override (hosted managed gateways are disabled)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for derived vendor hosts (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for derived vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit hosted tool-gateway access token (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n # ── Version 22 → 23: add display.preview_browser_backend (WebUI browser pane) ──\n if current_ver < 23:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"preview_browser_backend\" not in display:\n display[\"preview_browser_backend\"] = \"auto\"\n config[\"display\"] = display\n save_config(config)\n results[\"config_added\"].append(\"display.preview_browser_backend=auto (default)\")\n if not quiet:\n print(\" ✓ Added display.preview_browser_backend=auto\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "e149b71a50c0946d99f0a820d54b48eb26e86ff9": {"t": 1785768542.040228, "n": 144617, "h": "cb088cc060d5ac8c", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n # User-facing name for the agent, set during onboarding. Surfaced in the\n # system prompt so the agent knows what to call itself.\n \"name\": \"Spark\",\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n # WebUI Preview-tab streamed browser backend:\n # \"auto\" -> use agent-browser when available, else fall back to Playwright\n # \"agent-browser\" -> always agent-browser (shares the agent's browser session)\n # \"playwright\" -> always the Playwright StreamedBrowserSession\n \"preview_browser_backend\": \"auto\",\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Anthropic, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n \"onboarding_mode\": \"\", # \"recommended\" | \"minimal\" | \"none\" — set during setup\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 23,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (OpenRouter / Anthropic / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override (hosted managed gateways are disabled)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for derived vendor hosts (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for derived vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit hosted tool-gateway access token (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n # ── Version 22 → 23: add display.preview_browser_backend (WebUI browser pane) ──\n if current_ver < 23:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"preview_browser_backend\" not in display:\n display[\"preview_browser_backend\"] = \"auto\"\n config[\"display\"] = display\n save_config(config)\n results[\"config_added\"].append(\"display.preview_browser_backend=auto (default)\")\n if not quiet:\n print(\" ✓ Added display.preview_browser_backend=auto\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "97046c7c020eb5b534c8e17ccf3dab2dbae33275": {"t": 1785768545.820774, "n": 143631, "h": "fb7d5bc84ddaf7b6", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n # User-facing name for the agent, set during onboarding. Surfaced in the\n # system prompt so the agent knows what to call itself.\n \"name\": \"Spark\",\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Anthropic, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n \"onboarding_mode\": \"\", # \"recommended\" | \"minimal\" | \"none\" — set during setup\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 22,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (OpenRouter / Anthropic / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override (hosted managed gateways are disabled)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for derived vendor hosts (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for derived vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit hosted tool-gateway access token (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "50e54ac2a20de028e2d304c25b09df2d4745fc72": {"t": 1785768547.9218333, "n": 143373, "h": "5272a7662e9e4bf8", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Anthropic, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 21,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (OpenRouter / Anthropic / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override (hosted managed gateways are disabled)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for derived vendor hosts (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for derived vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit hosted tool-gateway access token (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "0c215ac6298639b1f940702325b7b39829f251a4": {"t": 1785768550.1875515, "n": 143374, "h": "66e0fd7889ba6f74", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Anthropic, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 21,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (OpenRouter / Anthropic / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override (hosted managed gateways are disabled)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for derived vendor hosts (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for derived vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit hosted tool-gateway access token (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(f\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "7078bf341caac8939b1b5fc1761e0912dfa76a4f": {"t": 1785768551.878871, "n": 143514, "h": "db4132b5fd63bb42", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Spark Portal, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 21,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (Spark Portal / OpenRouter / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override for Spark Portal Subscribers only (optional)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for Spark Portal Subscribers only, used to derive vendor hosts, e.g. automatedigital.ai -> automatedigital.ai\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for Spark Portal Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit Spark Portal Subscriber access token for tool-gateway requests (optional; otherwise read from the Spark auth store)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(f\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "46f361ef42787c2a883db2983b60b4831cde5424": {"t": 1785768554.8936226, "n": 144053, "h": "e8d1bb7409f73fc0", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\nfrom tools.tool_backend_helpers import (\n managed_nous_tools_enabled as _managed_nous_tools_enabled,\n)\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Spark Portal, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 21,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (Spark Portal / OpenRouter / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"NOUS_BASE_URL\": {\n \"description\": \"Spark Portal base URL override\",\n \"prompt\": \"Spark Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override for Spark Portal Subscribers only (optional)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for Spark Portal Subscribers only, used to derive vendor hosts, e.g. automatedigital.ai -> automatedigital.ai\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for Spark Portal Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit Spark Portal Subscriber access token for tool-gateway requests (optional; otherwise read from the Spark auth store)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nif not _managed_nous_tools_enabled():\n for _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n ):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(f\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "09213e3df81fd0cd5a420bbe12f2be7ddd45a9e7": {"t": 1785768557.7779667, "n": 143604, "h": "68a489fdf4710906", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\nfrom tools.tool_backend_helpers import (\n managed_nous_tools_enabled as _managed_nous_tools_enabled,\n)\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Spark Portal, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 21,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (Spark Portal / OpenRouter / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"NOUS_BASE_URL\": {\n \"description\": \"Spark Portal base URL override\",\n \"prompt\": \"Spark Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override for Spark Portal Subscribers only (optional)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for Spark Portal Subscribers only, used to derive vendor hosts, e.g. automatedigital.ai -> automatedigital.ai\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for Spark Portal Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit Spark Portal Subscriber access token for tool-gateway requests (optional; otherwise read from the Spark auth store)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nif not _managed_nous_tools_enabled():\n for _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n ):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(f\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n }\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "dcb56996649ea515ca6d807237634d84196140d3": {"t": 1785768559.4371915, "n": 142697, "h": "a2eacdbaa4d02ca9", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\nfrom tools.tool_backend_helpers import (\n managed_nous_tools_enabled as _managed_nous_tools_enabled,\n)\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Spark Portal, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 20,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (Spark Portal / OpenRouter / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"NOUS_BASE_URL\": {\n \"description\": \"Spark Portal base URL override\",\n \"prompt\": \"Spark Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override for Spark Portal Subscribers only (optional)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for Spark Portal Subscribers only, used to derive vendor hosts, e.g. automatedigital.ai -> automatedigital.ai\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for Spark Portal Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit Spark Portal Subscriber access token for tool-gateway requests (optional; otherwise read from the Spark auth store)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nif not _managed_nous_tools_enabled():\n for _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n ):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(f\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n }\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "f5686dcdabfbd4b122624ce31c316a30324cd14d": {"t": 1785768562.776881, "n": 142133, "h": "76037383926cc6ab", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\nfrom tools.tool_backend_helpers import (\n managed_nous_tools_enabled as _managed_nous_tools_enabled,\n)\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Spark Portal, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n \"_config_version\": 19,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (Spark Portal / OpenRouter / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"NOUS_BASE_URL\": {\n \"description\": \"Spark Portal base URL override\",\n \"prompt\": \"Spark Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override for Spark Portal Subscribers only (optional)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for Spark Portal Subscribers only, used to derive vendor hosts, e.g. automatedigital.ai -> automatedigital.ai\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for Spark Portal Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit Spark Portal Subscriber access token for tool-gateway requests (optional; otherwise read from the Spark auth store)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nif not _managed_nous_tools_enabled():\n for _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n ):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(f\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n }\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "e29f3fea07fee5342d2a73cd2f073cdd7aa3bda1": {"t": 1785768564.8617961, "n": 141262, "h": "efe24471084c672b", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\nfrom tools.tool_backend_helpers import (\n managed_nous_tools_enabled as _managed_nous_tools_enabled,\n)\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Inside umask(0o007) scope — SOUL.md will be created as 0660\n _ensure_default_soul_md(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Spark Portal, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n \"_config_version\": 19,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (Spark Portal / OpenRouter / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"NOUS_BASE_URL\": {\n \"description\": \"Spark Portal base URL override\",\n \"prompt\": \"Spark Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override for Spark Portal Subscribers only (optional)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for Spark Portal Subscribers only, used to derive vendor hosts, e.g. automatedigital.ai -> automatedigital.ai\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for Spark Portal Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit Spark Portal Subscriber access token for tool-gateway requests (optional; otherwise read from the Spark auth store)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nif not _managed_nous_tools_enabled():\n for _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n ):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(f\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n }\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "be49fd33efc298682cbdabc0101ac793f99702b1": {"t": 1785768567.7119691, "n": 141000, "h": "5287289cc630cd79", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\nfrom tools.tool_backend_helpers import (\n managed_nous_tools_enabled as _managed_nous_tools_enabled,\n)\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Inside umask(0o007) scope — SOUL.md will be created as 0660\n _ensure_default_soul_md(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Spark Portal, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n \"_config_version\": 19,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (Spark Portal / OpenRouter / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"NOUS_BASE_URL\": {\n \"description\": \"Spark Portal base URL override\",\n \"prompt\": \"Spark Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override for Spark Portal Subscribers only (optional)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for Spark Portal Subscribers only, used to derive vendor hosts, e.g. automatedigital.ai -> automatedigital.ai\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for Spark Portal Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit Spark Portal Subscriber access token for tool-gateway requests (optional; otherwise read from the Spark auth store)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nif not _managed_nous_tools_enabled():\n for _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n ):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(f\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n }\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "3337c6d992e569a4494621540463f3c67ef2d7ab": {"t": 1785768571.1181922, "n": 139826, "h": "7e57f83c1f097029", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\nfrom tools.tool_backend_helpers import (\n managed_nous_tools_enabled as _managed_nous_tools_enabled,\n)\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Inside umask(0o007) scope — SOUL.md will be created as 0660\n _ensure_default_soul_md(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Spark Portal, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n \"_config_version\": 19,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (Spark Portal / OpenRouter / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"NOUS_BASE_URL\": {\n \"description\": \"Spark Portal base URL override\",\n \"prompt\": \"Spark Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override for Spark Portal Subscribers only (optional)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for Spark Portal Subscribers only, used to derive vendor hosts, e.g. automatedigital.ai -> automatedigital.ai\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for Spark Portal Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit Spark Portal Subscriber access token for tool-gateway requests (optional; otherwise read from the Spark auth store)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nif not _managed_nous_tools_enabled():\n for _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n ):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(f\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n }\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "d25c06884529ce23e2e13804727d7fa14d93bb98": {"t": 1785768573.1283827, "n": 139535, "h": "eeed9e210131d72f", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\nfrom tools.tool_backend_helpers import (\n managed_nous_tools_enabled as _managed_nous_tools_enabled,\n)\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Inside umask(0o007) scope — SOUL.md will be created as 0660\n _ensure_default_soul_md(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Spark Portal, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n \"_config_version\": 18,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (Spark Portal / OpenRouter / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"NOUS_BASE_URL\": {\n \"description\": \"Spark Portal base URL override\",\n \"prompt\": \"Spark Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override for Spark Portal Subscribers only (optional)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for Spark Portal Subscribers only, used to derive vendor hosts, e.g. automatedigital.ai -> automatedigital.ai\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for Spark Portal Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit Spark Portal Subscriber access token for tool-gateway requests (optional; otherwise read from the Spark auth store)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nif not _managed_nous_tools_enabled():\n for _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n ):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(f\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n }\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "22208c9079f9564ea152bad3c0c6c140764a8804": {"t": 1785768575.0749898, "n": 139516, "h": "da83b67aaf28c085", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\nfrom tools.tool_backend_helpers import (\n managed_nous_tools_enabled as _managed_nous_tools_enabled,\n)\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import DEFAULT_SOUL_MD, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(DEFAULT_SOUL_MD, encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Inside umask(0o007) scope — SOUL.md will be created as 0660\n _ensure_default_soul_md(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Spark Portal, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n \"_config_version\": 18,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (Spark Portal / OpenRouter / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"NOUS_BASE_URL\": {\n \"description\": \"Spark Portal base URL override\",\n \"prompt\": \"Spark Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override for Spark Portal Subscribers only (optional)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for Spark Portal Subscribers only, used to derive vendor hosts, e.g. automatedigital.ai -> automatedigital.ai\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for Spark Portal Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit Spark Portal Subscriber access token for tool-gateway requests (optional; otherwise read from the Spark auth store)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nif not _managed_nous_tools_enabled():\n for _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n ):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(f\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n }\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "faaca9977dd3ae6b18a537b3ba935b4f1416bc12": {"t": 1785768578.1226823, "n": 139326, "h": "50313f271d5487fc", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\nfrom tools.tool_backend_helpers import (\n managed_nous_tools_enabled as _managed_nous_tools_enabled,\n)\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import DEFAULT_SOUL_MD\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n return\n soul_path.write_text(DEFAULT_SOUL_MD, encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Inside umask(0o007) scope — SOUL.md will be created as 0660\n _ensure_default_soul_md(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Spark Portal, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n \"_config_version\": 18,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (Spark Portal / OpenRouter / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"NOUS_BASE_URL\": {\n \"description\": \"Spark Portal base URL override\",\n \"prompt\": \"Spark Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override for Spark Portal Subscribers only (optional)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for Spark Portal Subscribers only, used to derive vendor hosts, e.g. automatedigital.ai -> automatedigital.ai\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for Spark Portal Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit Spark Portal Subscriber access token for tool-gateway requests (optional; otherwise read from the Spark auth store)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nif not _managed_nous_tools_enabled():\n for _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n ):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(f\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n }\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "30999cda9e5d9a6d7f66ab1f37640f1ea2e4be44": {"t": 1785768580.1061647, "n": 139315, "h": "d6e2b7dc20f57ef8", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\nfrom tools.tool_backend_helpers import (\n managed_nous_tools_enabled as _managed_nous_tools_enabled,\n)\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import DEFAULT_SOUL_MD\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n return\n soul_path.write_text(DEFAULT_SOUL_MD, encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Inside umask(0o007) scope — SOUL.md will be created as 0660\n _ensure_default_soul_md(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Spark Portal, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n \"_config_version\": 18,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (Spark Portal / OpenRouter / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"NOUS_BASE_URL\": {\n \"description\": \"Spark Portal base URL override\",\n \"prompt\": \"Spark Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override for Spark Portal Subscribers only (optional)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for Spark Portal Subscribers only, used to derive vendor hosts, e.g. automatedigital.ai -> automatedigital.ai\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for Spark Portal Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit Spark Portal Subscriber access token for tool-gateway requests (optional; otherwise read from the Spark auth store)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: home)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nif not _managed_nous_tools_enabled():\n for _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n ):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(f\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n }\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "e4233067bc0fbef75a1fe49896b3cbee4756ce0c": {"t": 1785768581.713814, "n": 138737, "h": "8b7f848ff86b5efb", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\nfrom tools.tool_backend_helpers import (\n managed_nous_tools_enabled as _managed_nous_tools_enabled,\n)\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import DEFAULT_SOUL_MD\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n return\n soul_path.write_text(DEFAULT_SOUL_MD, encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Inside umask(0o007) scope — SOUL.md will be created as 0660\n _ensure_default_soul_md(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {},\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Spark Portal, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n \"_config_version\": 18,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (Spark Portal / OpenRouter / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"NOUS_BASE_URL\": {\n \"description\": \"Spark Portal base URL override\",\n \"prompt\": \"Spark Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override for Spark Portal Subscribers only (optional)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for Spark Portal Subscribers only, used to derive vendor hosts, e.g. automatedigital.ai -> automatedigital.ai\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for Spark Portal Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit Spark Portal Subscriber access token for tool-gateway requests (optional; otherwise read from the Spark auth store)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: home)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nif not _managed_nous_tools_enabled():\n for _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n ):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(f\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n }\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Smart Model Routing ────────────────────────────────────────────────\n# Optional cheap-vs-strong routing for simple turns.\n# Keeps the primary model for complex work, but can route short/simple\n# messages to a cheaper model across providers.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openrouter\n# model: google/gemini-2.5-flash\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Smart Model Routing ────────────────────────────────────────────────\n# Optional cheap-vs-strong routing for simple turns.\n# Keeps the primary model for complex work, but can route short/simple\n# messages to a cheaper model across providers.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openrouter\n# model: google/gemini-2.5-flash\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "076d995eb4238eb96f114c5ae96c8d425f0004a4": {"t": 1785768587.2548268, "n": 138735, "h": "76e981d1721126cf", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\nfrom tools.tool_backend_helpers import (\n managed_nous_tools_enabled as _managed_nous_tools_enabled,\n)\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import DEFAULT_SOUL_MD\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n return\n soul_path.write_text(DEFAULT_SOUL_MD, encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Inside umask(0o007) scope — SOUL.md will be created as 0660\n _ensure_default_soul_md(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {},\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Spark Portal, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": True,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n \"_config_version\": 18,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (Spark Portal / OpenRouter / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"NOUS_BASE_URL\": {\n \"description\": \"Spark Portal base URL override\",\n \"prompt\": \"Spark Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override for Spark Portal Subscribers only (optional)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for Spark Portal Subscribers only, used to derive vendor hosts, e.g. automatedigital.ai -> automatedigital.ai\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for Spark Portal Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit Spark Portal Subscriber access token for tool-gateway requests (optional; otherwise read from the Spark auth store)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: home)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\nif not _managed_nous_tools_enabled():\n for _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n ):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, dict) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"api_key\",\n \"api_mode\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(f\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n }\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": True,\n \"dispatch_interval_seconds\": 60,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Smart Model Routing ────────────────────────────────────────────────\n# Optional cheap-vs-strong routing for simple turns.\n# Keeps the primary model for complex work, but can route short/simple\n# messages to a cheaper model across providers.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openrouter\n# model: google/gemini-2.5-flash\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n# nous (OAuth — spark auth) — Spark Portal\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Smart Model Routing ────────────────────────────────────────────────\n# Optional cheap-vs-strong routing for simple turns.\n# Keeps the primary model for complex work, but can route short/simple\n# messages to a cheaper model across providers.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openrouter\n# model: google/gemini-2.5-flash\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}, "1cbe5b1f2d3f3ad290aa5c432daf536f1e201bb2": {"t": 1786068513.7740588, "n": 153925, "h": "2352512e04583788", "c": "\"\"\"\nConfiguration management for Spark Agent.\n\nConfig files are stored in ~/.spark/ for easy access:\n- ~/.spark/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.spark/.env - API keys and secrets\n\nThis module provides:\n- spark config - Show current configuration\n- spark config edit - Open config in editor\n- spark config set - Set a specific value\n- spark config wizard - Re-run setup wizard\n\"\"\"\n\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset(\n {\n \"OPENAI_API_KEY\",\n \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\",\n \"TELEGRAM_HOME_CHANNEL\",\n \"SIGNAL_ACCOUNT\",\n \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\",\n \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"DINGTALK_CLIENT_ID\",\n \"DINGTALK_CLIENT_SECRET\",\n \"FEISHU_APP_ID\",\n \"FEISHU_APP_SECRET\",\n \"FEISHU_ENCRYPT_KEY\",\n \"FEISHU_VERIFICATION_TOKEN\",\n \"WECOM_BOT_ID\",\n \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\",\n \"WECOM_CALLBACK_CORP_SECRET\",\n \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\",\n \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\",\n \"WECOM_CALLBACK_PORT\",\n \"WEIXIN_ACCOUNT_ID\",\n \"WEIXIN_TOKEN\",\n \"WEIXIN_BASE_URL\",\n \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\",\n \"WEIXIN_HOME_CHANNEL_NAME\",\n \"WEIXIN_DM_POLICY\",\n \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\",\n \"WEIXIN_GROUP_ALLOWED_USERS\",\n \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\",\n \"BLUEBUBBLES_PASSWORD\",\n \"QQ_APP_ID\",\n \"QQ_CLIENT_SECRET\",\n \"QQ_HOME_CHANNEL\",\n \"QQ_HOME_CHANNEL_NAME\",\n \"QQ_ALLOWED_USERS\",\n \"QQ_GROUP_ALLOWED_USERS\",\n \"QQ_ALLOW_ALL_USERS\",\n \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\",\n \"QQ_STT_BASE_URL\",\n \"QQ_STT_MODEL\",\n \"TERMINAL_ENV\",\n \"TERMINAL_SSH_KEY\",\n \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\",\n \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\",\n \"MATRIX_ENCRYPTION\",\n \"MATRIX_DEVICE_ID\",\n \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\",\n \"MATRIX_FREE_RESPONSE_ROOMS\",\n \"MATRIX_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n }\n)\nimport yaml\n\nfrom spark_cli.colors import Colors, color\nfrom spark_cli.default_soul import read_default_soul_md, should_replace_with_default_soul\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_spark_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Spark is running in package-manager-managed mode.\n\n Two signals: the SPARK_MANAGED env var (set by the systemd service),\n or a .managed marker file in SPARK_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade spark-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"spark update\"\n\n\ndef format_managed_message(action: str = \"modify this Spark installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"SPARK_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Spark installation is managed by NixOS \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Edit services.spark-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Spark installation is managed by Homebrew \"\n f\"(SPARK_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade spark-agent\"\n )\n\n return (\n f\"Cannot {action}: this Spark installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Spark.\"\n )\n\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from SPARK_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, spark_bin\n or None if container mode is not active, we're already inside the\n container, or SPARK_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"SPARK_DEV\") == \"1\":\n return None\n\n from core.spark_constants import is_container\n\n if is_container():\n return None\n\n container_mode_file = get_spark_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file) as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"spark-agent\")\n exec_user = info.get(\"exec_user\", \"spark\")\n spark_bin = info.get(\"spark_bin\", \"/data/current-package/bin/spark\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"spark_bin\": spark_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from spark_constants — canonical definition lives there.\nfrom core.spark_constants import get_spark_home # noqa: F811,E402\n\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_spark_home() / \"config.yaml\"\n\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_spark_home() / \".env\"\n\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the spark group can\n share state with the gateway service.\n\n The mode can be overridden via the SPARK_HOME_MODE environment variable\n (e.g. SPARK_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse SPARK_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"SPARK_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n \"\"\"\n if is_managed():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_wiki_readme(home: Path) -> None:\n \"\"\"Seed a default README.md into workspace/wiki if absent.\"\"\"\n readme = home / \"workspace\" / \"wiki\" / \"README.md\"\n if readme.exists():\n return\n readme.write_text(\n \"# Spark Wiki\\n\\n\"\n \"Personal knowledge base populated by `/dream` reflection passes.\\n\\n\"\n \"## Structure\\n\\n\"\n \"- `dreams/` — daily reflection summaries written by `/dream`\\n\"\n \"- `log.md` — chronological append-only log of wiki events\\n\",\n encoding=\"utf-8\",\n )\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into SPARK_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n try:\n if not should_replace_with_default_soul(soul_path.read_text(encoding=\"utf-8\")):\n return\n except OSError:\n return\n soul_path.write_text(read_default_soul_md() + \"\\n\", encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_spark_home():\n \"\"\"Ensure ~/.spark directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_spark_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_spark_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\",\n \"workspace\", \"workspace/wiki\", \"workspace/wiki/dreams\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\ndef _ensure_spark_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"SPARK_HOME {home} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. Run 'sudo nixos-rebuild switch' first.\"\n )\n # Wiki dirs are user-content, not system-managed — create if missing.\n (home / \"workspace\" / \"wiki\" / \"dreams\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md and README will be created as 0660\n _ensure_default_soul_md(home)\n _ensure_default_wiki_readme(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n # Keep the historical string shape. The canonical role-based Auto policy\n # lives under smart_model_routing.auto; model_config handles old strings\n # and newer model dictionaries without forcing a config rewrite.\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"spark-cli\"],\n \"agent\": {\n # User-facing name for the agent, set during onboarding. Surfaced in the\n # system prompt so the agent knows what to call itself.\n \"name\": \"Spark\",\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n \"restart_drain_timeout\": 60,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Dependency-aware effect scheduler. Set false to restore the\n # conservative sequential executor for newly created agent runs.\n \"dependency_scheduler\": True,\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n \"gateway_notify_interval\": 600,\n # Global reasoning effort for the main agent (empty = model default).\n # Values: \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", or \"\" to let the\n # model decide. Only takes effect on reasoning-capable models.\n \"reasoning_effort\": \"\",\n },\n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Spark\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example: [\"/home/user/projects:/workspace/projects\", \"/data:/data\"]\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow browser/CDP access to private/internal IPs beyond loopback previews\n \"camofox\": {\n # When true, Spark sends a stable profile-scoped userId to Camofox\n # so the server can map it to a persistent browser profile directory.\n # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once per\n # conversation turn (on first write_file/patch call). Use /rollback to restore.\n \"checkpoints\": {\n \"enabled\": True,\n \"max_snapshots\": 50, # Max checkpoints to keep per directory\n },\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n },\n \"smart_model_routing\": {\n # Multi-model routing: keep model.default as the SMART model for\n # complex/coding work, and optionally route short simple turns to the\n # FAST model below. Configure interactively with `spark model`.\n \"enabled\": False,\n \"max_simple_chars\": 160,\n \"max_simple_words\": 28,\n \"cheap_model\": {\n \"provider\": \"\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_mode\": \"\",\n },\n # The role-based Auto policy is additive. Legacy fields above remain\n # readable and writable so existing config files keep working.\n \"auto\": {\n \"enabled\": True,\n \"hysteresis_margin\": 2,\n \"context_threshold\": 24000,\n \"roles\": {\n \"lead\": {\"provider\": \"openai-codex\", \"model\": \"gpt-5.6-sol\", \"reasoning_effort\": \"high\", \"fallback\": [\"balanced\"]},\n \"balanced\": {\"provider\": \"openai-codex\", \"model\": \"gpt-5.6-terra\", \"reasoning_effort\": \"medium\", \"fallback\": [\"fast\"]},\n \"fast\": {\"provider\": \"openai-codex\", \"model\": \"gpt-5.6-luna\", \"reasoning_effort\": \"low\", \"fallback\": [\"balanced\"]},\n \"subagent\": {\"provider\": \"openai-codex\", \"model\": \"gpt-5.6-luna\", \"reasoning_effort\": \"high\", \"fallback\": [\"balanced\"]},\n },\n },\n },\n \"response_budget\": {\n # Cache-stable action-first guidance. Adaptive model routing remains\n # independently controlled by smart_model_routing.enabled.\n \"style_enabled\": True,\n \"soft_output_caps\": True,\n },\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | openai-codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"flush_memories\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n },\n \"dream\": {\n \"provider\": \"auto\",\n \"model\": \"\", # capable model recommended; defaults to main model when empty\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600, # seconds — synthesis over many transcripts can be slow\n },\n },\n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\",\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": True,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n \"interim_assistant_messages\": True, # Gateway: show natural mid-turn assistant status messages\n \"tool_progress_command\": False, # Enable /verbose command in messaging gateway\n \"tool_progress_overrides\": {}, # DEPRECATED — use display.platforms instead\n \"tool_preview_length\": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)\n \"platforms\": {}, # Per-platform display overrides: {\"telegram\": {\"tool_progress\": \"all\"}, \"slack\": {\"tool_progress\": \"off\"}}\n # WebUI Preview-tab streamed browser backend:\n # \"auto\" -> use agent-browser when available, else fall back to Playwright\n # \"agent-browser\" -> always agent-browser (shares the agent's browser session)\n # \"playwright\" -> always the Playwright StreamedBrowserSession\n \"preview_browser_backend\": \"auto\",\n \"chat_word_wrap\": False, # WebUI: default word wrap for chat code blocks and markdown tables\n },\n # Privacy settings\n \"privacy\": {\n \"redact_pii\": False, # When True, hash user IDs and strip phone numbers from LLM context\n },\n # Text-to-speech configuration\n \"tts\": {\n \"provider\": \"edge\", # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\" (local) | \"piper\" (local)\n \"edge\": {\n \"voice\": \"en-US-AriaNeural\",\n # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural\n },\n \"elevenlabs\": {\n \"voice_id\": \"pNInz6obpgDQGcFmaJgB\", # Adam\n \"model_id\": \"eleven_multilingual_v2\",\n },\n \"openai\": {\n \"model\": \"gpt-4o-mini-tts\",\n \"voice\": \"alloy\",\n # Voices: alloy, echo, fable, onyx, nova, shimmer\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-tts-2603\",\n \"voice_id\": \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\", # Paul - Neutral\n },\n \"neutts\": {\n \"ref_audio\": \"\", # Path to reference voice audio (empty = bundled default)\n \"ref_text\": \"\", # Path to reference voice transcript (empty = bundled default)\n \"model\": \"neuphonic/neutts-air-q4-gguf\", # HuggingFace model repo\n \"device\": \"cpu\", # cpu, cuda, or mps\n },\n # Piper: local neural VITS TTS, no API key required, 44+ languages.\n # Install with: pip install piper-tts\n # Voice models are downloaded automatically to ~/.spark/cache/piper-voices/ on first use.\n # Browse voices: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n \"piper\": {\n \"voice\": \"en_US-lessac-medium\", # e.g. en_US-hfc_female, en_US-lessac-medium, en_GB-alan-medium\n \"voices_dir\": \"\", # Override download dir (empty = ~/.spark/cache/piper-voices/)\n \"use_cuda\": False, # Enable GPU acceleration (requires CUDA)\n },\n },\n \"stt\": {\n \"enabled\": True,\n \"provider\": \"local\", # \"local\" (free, faster-whisper) | \"groq\" | \"openai\" (Whisper API) | \"mistral\" (Voxtral Transcribe)\n \"local\": {\n \"model\": \"base\", # tiny, base, small, medium, large-v3\n \"language\": \"\", # auto-detect by default; set to \"en\", \"es\", \"fr\", etc. to force\n },\n \"openai\": {\n \"model\": \"whisper-1\", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe\n },\n \"mistral\": {\n \"model\": \"voxtral-mini-latest\", # voxtral-mini-latest, voxtral-mini-2602\n },\n },\n \"voice\": {\n \"record_key\": \"ctrl+b\",\n \"max_recording_seconds\": 120,\n \"auto_tts\": False,\n \"silence_threshold\": 200, # RMS below this = silence (0-32767)\n \"silence_duration\": 3.0, # Seconds of silence before auto-stop\n },\n \"human_delay\": {\n \"mode\": \"off\",\n \"min_ms\": 800,\n \"max_ms\": 2500,\n },\n # Context engine -- controls how the context window is managed when\n # approaching the model's token limit.\n # \"compressor\" = built-in lossy summarization (default).\n # Set to a plugin name to activate an alternative engine (e.g. \"lcm\"\n # for Lossless Context Management). The engine must be installed as\n # a plugin in plugins/context_engine/<name>/ or ~/.spark/plugins/.\n \"context\": {\n \"engine\": \"compressor\",\n },\n # Persistent memory -- bounded curated memory injected into system prompt\n \"memory\": {\n \"memory_enabled\": True,\n \"user_profile_enabled\": True,\n \"memory_char_limit\": 2200, # ~800 tokens at 2.75 chars/token\n \"user_char_limit\": 1375, # ~500 tokens at 2.75 chars/token\n # External memory provider plugin (empty = built-in only).\n # Set to a provider name to activate: \"holographic\" (default, local SQLite),\n # \"openviking\", \"mem0\", \"hindsight\", \"retaindb\", \"byterover\".\n # Only ONE external provider is allowed at a time.\n \"provider\": \"holographic\",\n },\n # Skill curator — background maintenance of agent-created skills.\n # Runs in an idle window after the configured interval.\n # Use /curator status|pause|resume|run to manage.\n \"curator\": {\n \"enabled\": True,\n \"interval_hours\": 168, # 7 days between curator passes\n \"min_idle_hours\": 2, # only trigger when the agent has been idle this long\n \"stale_after_days\": 30, # mark skills stale after N days without use\n \"archive_after_days\": 90, # archive stale skills after N more days\n },\n # Subagent delegation — override the provider:model used by delegate_task\n # so child agents can run on a different (cheaper/faster) provider and model.\n # Uses the same runtime provider resolution as CLI/gateway startup, so all\n # configured providers (OpenRouter, Anthropic, Z.ai, Kimi, etc.) are supported.\n \"delegation\": {\n \"model\": \"\", # e.g. \"google/gemini-3-flash-preview\" (empty = inherit parent model)\n \"provider\": \"\", # e.g. \"openrouter\" (empty = inherit parent provider + credentials)\n \"base_url\": \"\", # direct OpenAI-compatible endpoint for subagents\n \"api_key\": \"\", # API key for delegation.base_url (falls back to OPENAI_API_KEY)\n \"max_iterations\": 50, # per-subagent iteration cap (each subagent gets its own budget,\n # independent of the parent's max_iterations)\n \"reasoning_effort\": \"\", # reasoning effort for subagents: \"xhigh\", \"high\", \"medium\",\n # \"low\", \"minimal\", \"none\" (empty = inherit parent's level)\n },\n # Ephemeral prefill messages file — JSON list of {role, content} dicts\n # injected at the start of every API call for few-shot priming.\n # Never saved to sessions, logs, or trajectories.\n \"prefill_messages_file\": \"\",\n # Skills — external skill directories for sharing skills across tools/agents.\n # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation\n # always goes to ~/.spark/skills/.\n \"skills\": {\n \"external_dirs\": [], # e.g. [\"~/.agents/skills\", \"/shared/team-skills\"]\n \"onboarding_mode\": \"\", # \"recommended\" | \"minimal\" | \"none\" — set during setup\n },\n # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.\n # This section is only needed for spark-specific overrides; everything else\n # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.\n \"honcho\": {},\n # IANA timezone (e.g. \"Asia/Kolkata\", \"America/New_York\").\n # Empty string means use server-local time.\n \"timezone\": \"\",\n # Discord platform settings (gateway mode)\n \"discord\": {\n \"require_mention\": True, # Require @mention to respond in server channels\n \"free_response_channels\": \"\", # Comma-separated channel IDs where bot responds without mention\n \"allowed_channels\": \"\", # If set, bot ONLY responds in these channel IDs (whitelist)\n \"auto_thread\": True, # Auto-create threads on @mention in channels (like Slack)\n \"reactions\": True, # Add 👀/✅/❌ reactions to messages during processing\n },\n # WhatsApp platform settings (gateway mode)\n \"whatsapp\": {\n # Reply prefix prepended to every outgoing WhatsApp message.\n # Default (None) uses the built-in \"S *Spark Agent*\" header.\n # Set to \"\" (empty string) to disable the header entirely.\n # Supports \\n for newlines, e.g. \"🤖 *My Bot*\\n──────\\n\"\n },\n # Approval mode for dangerous commands:\n # manual — always prompt the user (default)\n # smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk\n # off — skip all approval prompts (equivalent to --yolo)\n \"approvals\": {\n \"mode\": \"manual\",\n \"timeout\": 60,\n },\n # Permanently allowed dangerous command patterns (added via \"always\" approval)\n \"command_allowlist\": [],\n # User-defined quick commands that bypass the agent loop (type: exec only)\n \"quick_commands\": {},\n # Custom personalities — add your own entries here\n # Supports string format: {\"name\": \"system prompt\"}\n # Or dict format: {\"name\": {\"description\": \"...\", \"system_prompt\": \"...\", \"tone\": \"...\", \"style\": \"...\"}}\n \"personalities\": {},\n # Pre-exec security scanning via tirith\n \"security\": {\n \"redact_secrets\": True,\n # When True, the agent browser pauses before sensitive actions\n # (submitting payments, sending messages, logging into a NEW domain)\n # and returns a needs_confirmation result the user must approve before\n # the action runs. See src/tools/browser_permission_gate.py.\n \"browser_confirm_sensitive\": True,\n \"tirith_enabled\": True,\n \"tirith_path\": \"tirith\",\n \"tirith_timeout\": 5,\n \"tirith_fail_open\": True,\n \"website_blocklist\": {\n \"enabled\": False,\n \"domains\": [],\n \"shared_files\": [],\n },\n },\n \"cron\": {\n # Wrap delivered cron responses with a header (task name) and footer\n # (\"The agent cannot see this message\"). Set to false for clean output.\n \"wrap_response\": True,\n },\n # Logging — controls file logging to ~/.spark/logs/.\n # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.\n \"logging\": {\n \"level\": \"INFO\", # Minimum level for agent.log: DEBUG, INFO, WARNING\n \"max_size_mb\": 5, # Max size per log file before rotation\n \"backup_count\": 3, # Number of rotated backup files to keep\n },\n # Network settings — workarounds for connectivity issues.\n \"network\": {\n # Force IPv4 connections. On servers with broken or unreachable IPv6,\n # Python tries AAAA records first and hangs for the full TCP timeout\n # before falling back to IPv4. Set to true to skip IPv6 entirely.\n \"force_ipv4\": False,\n # Optional PEM bundle of trusted CAs for provider HTTPS calls.\n # Supports ~ and ${ENV_VAR} expansion.\n \"ca_bundle\": \"\",\n },\n # Config schema version - bump this when adding new required fields\n # Web dashboard (FastAPI + React) — also embeds in gateway when enabled.\n \"dashboard\": {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n # When true, API requests must be same-origin (Origin matches Host) or\n # carry Authorization: Bearer <dashboard.token>. Loopback peers without\n # Origin (e.g. curl) are allowed for local tooling.\n \"require_auth_nonlocal\": True,\n # Optional public-facing URL override (e.g. \"https://spark.example.com\").\n # When set, Spark uses this in all user-facing links instead of deriving\n # the address from the bind host. Leave empty to auto-detect.\n \"public_url\": \"\",\n # Show first-class delegated sub-agents in the chat right sidebar.\n # Default on for v1, but keep a kill switch while the lifecycle API settles.\n \"subagents_sidebar\": True,\n },\n # macOS / desktop app behaviour.\n \"desktop\": {\n # When true, the desktop app auto-starts the messaging gateway in the\n # background on launch (so platforms like Telegram/Discord can reach\n # Spark while the app is open) and stops it on quit. Set false to opt\n # out — the app then never spawns its own gateway and leaves any\n # user-managed external gateway untouched.\n \"gateway_autostart\": True,\n },\n # Multi-agent Kanban board (SQLite kanban.db under SPARK_HOME).\n \"kanban\": {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"workflow_in_gateway\": False,\n \"workflow_interval_seconds\": 10,\n \"notify_on_changes\": False,\n \"wake_creator_on_changes\": False,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n },\n # Post-dispatch tool-output pipeline (TokenJuice compaction +\n # prompt-injection screening). Both layers are off by default; opt in\n # via `spark config set tool_pipeline.normalization.enabled true` etc.\n \"tool_pipeline\": {\n \"normalization\": {\n \"enabled\": False,\n \"user_rules_dir\": \"\", # empty = ${SPARK_HOME}/normalization_rules\n },\n \"injection_guard\": {\n \"mode\": \"off\", # \"off\" | \"warn\" | \"enforce\"\n \"block_threshold\": 0.70,\n \"review_threshold\": 0.45,\n },\n },\n \"_config_version\": 29,\n}\n\n# =============================================================================\n# Config Migration System\n# =============================================================================\n\n# Track which env vars were introduced in each config version.\n# Migration only mentions vars new since the user's previous version.\nENV_VARS_BY_VERSION: Dict[int, List[str]] = {\n 3: [\n \"FIRECRAWL_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"FAL_KEY\",\n ],\n 4: [\"VOICE_TOOLS_OPENAI_KEY\", \"ELEVENLABS_API_KEY\"],\n 5: [\n \"WHATSAPP_ENABLED\",\n \"WHATSAPP_MODE\",\n \"WHATSAPP_ALLOWED_USERS\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"SLACK_ALLOWED_USERS\",\n ],\n 10: [\"TAVILY_API_KEY\"],\n 11: [\"TERMINAL_MODAL_MODE\"],\n}\n\n# Required environment variables with metadata for migration prompts.\n# LLM provider is required but handled in the setup wizard's provider\n# selection step (OpenRouter / Anthropic / Custom endpoint), so this\n# dict is intentionally empty — no single env var is universally required.\nREQUIRED_ENV_VARS = {}\n\n# Optional environment variables that enhance functionality\nOPTIONAL_ENV_VARS = {\n # ── Provider (handled in provider selection, not shown in checklists) ──\n \"OPENROUTER_API_KEY\": {\n \"description\": \"OpenRouter API key (for vision, web scraping helpers, and MoA)\",\n \"prompt\": \"OpenRouter API key\",\n \"url\": \"https://openrouter.ai/keys\",\n \"password\": True,\n \"tools\": [\"vision_analyze\", \"mixture_of_agents\"],\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GOOGLE_API_KEY\": {\n \"description\": \"Google AI Studio API key (also recognized as GEMINI_API_KEY)\",\n \"prompt\": \"Google AI Studio API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_API_KEY\": {\n \"description\": \"Google AI Studio API key (alias for GOOGLE_API_KEY)\",\n \"prompt\": \"Gemini API key\",\n \"url\": \"https://aistudio.google.com/app/apikey\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GEMINI_BASE_URL\": {\n \"description\": \"Google AI Studio base URL override\",\n \"prompt\": \"Gemini base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_API_KEY\": {\n \"description\": \"Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)\",\n \"prompt\": \"Z.AI / GLM API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ZAI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"Z_AI_API_KEY\": {\n \"description\": \"Z.AI API key (alias for GLM_API_KEY)\",\n \"prompt\": \"Z.AI API key\",\n \"url\": \"https://z.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"GLM_BASE_URL\": {\n \"description\": \"Z.AI / GLM base URL override\",\n \"prompt\": \"Z.AI / GLM base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_API_KEY\": {\n \"description\": \"Kimi / Moonshot API key\",\n \"prompt\": \"Kimi API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_BASE_URL\": {\n \"description\": \"Kimi / Moonshot base URL override\",\n \"prompt\": \"Kimi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"KIMI_CN_API_KEY\": {\n \"description\": \"Kimi / Moonshot China API key\",\n \"prompt\": \"Kimi (China) API key\",\n \"url\": \"https://platform.moonshot.cn/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEEAI_API_KEY\": {\n \"description\": \"Arcee AI API key\",\n \"prompt\": \"Arcee AI API key\",\n \"url\": \"https://chat.arcee.ai/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"ARCEE_BASE_URL\": {\n \"description\": \"Arcee AI base URL override\",\n \"prompt\": \"Arcee base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_API_KEY\": {\n \"description\": \"MiniMax API key (international)\",\n \"prompt\": \"MiniMax API key\",\n \"url\": \"https://www.minimax.io/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_BASE_URL\": {\n \"description\": \"MiniMax base URL override\",\n \"prompt\": \"MiniMax base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_API_KEY\": {\n \"description\": \"MiniMax API key (China endpoint)\",\n \"prompt\": \"MiniMax (China) API key\",\n \"url\": \"https://www.minimaxi.com/\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"MINIMAX_CN_BASE_URL\": {\n \"description\": \"MiniMax (China) base URL override\",\n \"prompt\": \"MiniMax (China) base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"DEEPSEEK_API_KEY\": {\n \"description\": \"DeepSeek API key for direct DeepSeek access\",\n \"prompt\": \"DeepSeek API Key\",\n \"url\": \"https://platform.deepseek.com/api_keys\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DEEPSEEK_BASE_URL\": {\n \"description\": \"Custom DeepSeek API base URL (advanced)\",\n \"prompt\": \"DeepSeek Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_API_KEY\": {\n \"description\": \"Alibaba Cloud DashScope API key (Qwen + multi-provider models)\",\n \"prompt\": \"DashScope API Key\",\n \"url\": \"https://modelstudio.console.alibabacloud.com/\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"DASHSCOPE_BASE_URL\": {\n \"description\": \"Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)\",\n \"prompt\": \"DashScope Base URL\",\n \"url\": \"\",\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"SPARK_QWEN_BASE_URL\": {\n \"description\": \"Qwen Portal base URL override (default: https://portal.qwen.ai/v1)\",\n \"prompt\": \"Qwen Portal base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_API_KEY\": {\n \"description\": \"OpenCode Zen API key (pay-as-you-go access to curated models)\",\n \"prompt\": \"OpenCode Zen API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_ZEN_BASE_URL\": {\n \"description\": \"OpenCode Zen base URL override\",\n \"prompt\": \"OpenCode Zen base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_API_KEY\": {\n \"description\": \"OpenCode Go API key ($10/month subscription for open models)\",\n \"prompt\": \"OpenCode Go API key\",\n \"url\": \"https://opencode.ai/auth\",\n \"password\": True,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"OPENCODE_GO_BASE_URL\": {\n \"description\": \"OpenCode Go base URL override\",\n \"prompt\": \"OpenCode Go base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"HF_TOKEN\": {\n \"description\": \"Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)\",\n \"prompt\": \"Hugging Face Token\",\n \"url\": \"https://huggingface.co/settings/tokens\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"HF_BASE_URL\": {\n \"description\": \"Hugging Face Inference Providers base URL override\",\n \"prompt\": \"HF base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n \"XIAOMI_API_KEY\": {\n \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n \"prompt\": \"Xiaomi MiMo API Key\",\n \"url\": \"https://platform.xiaomimimo.com\",\n \"password\": True,\n \"category\": \"provider\",\n },\n \"XIAOMI_BASE_URL\": {\n \"description\": \"Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)\",\n \"prompt\": \"Xiaomi base URL (leave empty for default)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"provider\",\n \"advanced\": True,\n },\n # ── Tool API keys ──\n \"EXA_API_KEY\": {\n \"description\": \"Exa API key for AI-native web search and contents\",\n \"prompt\": \"Exa API key\",\n \"url\": \"https://exa.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"PARALLEL_API_KEY\": {\n \"description\": \"Parallel API key for AI-native web search and extract\",\n \"prompt\": \"Parallel API key\",\n \"url\": \"https://parallel.ai/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_KEY\": {\n \"description\": \"Firecrawl API key for web search and scraping\",\n \"prompt\": \"Firecrawl API key\",\n \"url\": \"https://firecrawl.dev/\",\n \"tools\": [\"web_search\", \"web_extract\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_API_URL\": {\n \"description\": \"Firecrawl API URL for self-hosted instances (optional)\",\n \"prompt\": \"Firecrawl API URL (leave empty for cloud)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"FIRECRAWL_GATEWAY_URL\": {\n \"description\": \"Exact Firecrawl tool-gateway origin override (hosted managed gateways are disabled)\",\n \"prompt\": \"Firecrawl gateway URL (leave empty to derive from domain)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_DOMAIN\": {\n \"description\": \"Shared tool-gateway domain suffix for derived vendor hosts (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway domain suffix\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_SCHEME\": {\n \"description\": \"Shared tool-gateway URL scheme for derived vendor hosts (`https` by default, set `http` for local gateway testing)\",\n \"prompt\": \"Tool-gateway URL scheme\",\n \"url\": None,\n \"password\": False,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TOOL_GATEWAY_USER_TOKEN\": {\n \"description\": \"Explicit hosted tool-gateway access token (hosted managed gateways are disabled)\",\n \"prompt\": \"Tool-gateway user token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"tool\",\n \"advanced\": True,\n },\n \"TAVILY_API_KEY\": {\n \"description\": \"Tavily API key for AI-native web search, extract, and crawl\",\n \"prompt\": \"Tavily API key\",\n \"url\": \"https://app.tavily.com/home\",\n \"tools\": [\"web_search\", \"web_extract\", \"web_crawl\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_API_KEY\": {\n \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browserbase API key\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"BROWSERBASE_PROJECT_ID\": {\n \"description\": \"Browserbase project ID (optional — only needed for cloud browser)\",\n \"prompt\": \"Browserbase project ID\",\n \"url\": \"https://browserbase.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"BROWSER_USE_API_KEY\": {\n \"description\": \"Browser Use API key for cloud browser (optional — local browser works without this)\",\n \"prompt\": \"Browser Use API key\",\n \"url\": \"https://browser-use.com/\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"FIRECRAWL_BROWSER_TTL\": {\n \"description\": \"Firecrawl browser session TTL in seconds (optional, default 300)\",\n \"prompt\": \"Browser session TTL (seconds)\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"CAMOFOX_URL\": {\n \"description\": \"Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)\",\n \"prompt\": \"Camofox server URL\",\n \"url\": \"https://github.com/jo-inc/camofox-browser\",\n \"tools\": [\"browser_navigate\", \"browser_click\"],\n \"password\": False,\n \"category\": \"tool\",\n },\n \"FAL_KEY\": {\n \"description\": \"FAL API key for image generation\",\n \"prompt\": \"FAL API key\",\n \"url\": \"https://fal.ai/\",\n \"tools\": [\"image_generate\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"TINKER_API_KEY\": {\n \"description\": \"Tinker API key for RL training\",\n \"prompt\": \"Tinker API key\",\n \"url\": \"https://tinker-console.thinkingmachines.ai/keys\",\n \"tools\": [\"rl_start_training\", \"rl_check_status\", \"rl_stop_training\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"WANDB_API_KEY\": {\n \"description\": \"Weights & Biases API key for experiment tracking\",\n \"prompt\": \"WandB API key\",\n \"url\": \"https://wandb.ai/authorize\",\n \"tools\": [\"rl_get_results\", \"rl_check_status\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"VOICE_TOOLS_OPENAI_KEY\": {\n \"description\": \"OpenAI API key for voice transcription (Whisper) and OpenAI TTS\",\n \"prompt\": \"OpenAI API Key (for Whisper STT + TTS)\",\n \"url\": \"https://platform.openai.com/api-keys\",\n \"tools\": [\"voice_transcription\", \"openai_tts\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"ELEVENLABS_API_KEY\": {\n \"description\": \"ElevenLabs API key for premium text-to-speech voices\",\n \"prompt\": \"ElevenLabs API key\",\n \"url\": \"https://elevenlabs.io/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"MISTRAL_API_KEY\": {\n \"description\": \"Mistral API key for Voxtral TTS and transcription (STT)\",\n \"prompt\": \"Mistral API key\",\n \"url\": \"https://console.mistral.ai/\",\n \"password\": True,\n \"category\": \"tool\",\n },\n \"GITHUB_TOKEN\": {\n \"description\": \"GitHub token for Skills Hub (higher API rate limits, skill publish)\",\n \"prompt\": \"GitHub Token\",\n \"url\": \"https://github.com/settings/tokens\",\n \"password\": True,\n \"category\": \"tool\",\n },\n # ── Honcho ──\n \"HONCHO_API_KEY\": {\n \"description\": \"Honcho API key for AI-native persistent memory\",\n \"prompt\": \"Honcho API key\",\n \"url\": \"https://app.honcho.dev\",\n \"tools\": [\"honcho_context\"],\n \"password\": True,\n \"category\": \"tool\",\n },\n \"HONCHO_BASE_URL\": {\n \"description\": \"Base URL for self-hosted Honcho instances (no API key needed)\",\n \"prompt\": \"Honcho base URL (e.g. http://localhost:8000)\",\n \"category\": \"tool\",\n },\n # ── Messaging platforms ──\n \"TELEGRAM_BOT_TOKEN\": {\n \"description\": \"Telegram bot token from @BotFather\",\n \"prompt\": \"Telegram bot token\",\n \"url\": \"https://t.me/BotFather\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"TELEGRAM_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)\",\n \"prompt\": \"Allowed Telegram user IDs (comma-separated)\",\n \"url\": \"https://t.me/userinfobot\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_BOT_TOKEN\": {\n \"description\": \"Discord bot token from Developer Portal\",\n \"prompt\": \"Discord bot token\",\n \"url\": \"https://discord.com/developers/applications\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"DISCORD_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Discord user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Discord user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"DISCORD_REPLY_TO_MODE\": {\n \"description\": \"Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)\",\n \"prompt\": \"Discord reply mode (off/first/all)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"SLACK_BOT_TOKEN\": {\n \"description\": \"Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. \"\n \"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, \"\n \"im:history, im:read, im:write, users:read, files:read, files:write\",\n \"prompt\": \"Slack Bot Token (xoxb-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"SLACK_APP_TOKEN\": {\n \"description\": \"Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → \"\n \"App-Level Tokens. Also ensure Event Subscriptions include: message.im, \"\n \"message.channels, message.groups, app_mention\",\n \"prompt\": \"Slack App Token (xapp-...)\",\n \"url\": \"https://api.slack.com/apps\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_URL\": {\n \"description\": \"Mattermost server URL (e.g. https://mm.example.com)\",\n \"prompt\": \"Mattermost server URL\",\n \"url\": \"https://mattermost.com/deploy/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_TOKEN\": {\n \"description\": \"Mattermost bot token or personal access token\",\n \"prompt\": \"Mattermost bot token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Mattermost user IDs allowed to use the bot\",\n \"prompt\": \"Allowed Mattermost user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in channels\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATTERMOST_FREE_RESPONSE_CHANNELS\": {\n \"description\": \"Comma-separated Mattermost channel IDs where bot responds without @mention\",\n \"prompt\": \"Free-response channel IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_HOMESERVER\": {\n \"description\": \"Matrix homeserver URL (e.g. https://matrix.example.org)\",\n \"prompt\": \"Matrix homeserver URL\",\n \"url\": \"https://matrix.org/ecosystem/servers/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ACCESS_TOKEN\": {\n \"description\": \"Matrix access token (preferred over password login)\",\n \"prompt\": \"Matrix access token\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"MATRIX_USER_ID\": {\n \"description\": \"Matrix user ID (e.g. @spark:example.org)\",\n \"prompt\": \"Matrix user ID (@user:server)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_ALLOWED_USERS\": {\n \"description\": \"Comma-separated Matrix user IDs allowed to use the bot (@user:server format)\",\n \"prompt\": \"Allowed Matrix user IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"MATRIX_REQUIRE_MENTION\": {\n \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n \"prompt\": \"Require @mention in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_FREE_RESPONSE_ROOMS\": {\n \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n \"prompt\": \"Free-response room IDs (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_AUTO_THREAD\": {\n \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n \"prompt\": \"Auto-create threads in rooms (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_DEVICE_ID\": {\n \"description\": \"Stable Matrix device ID for E2EE persistence across restarts (e.g. SPARK_BOT)\",\n \"prompt\": \"Matrix device ID (stable across restarts)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"MATRIX_RECOVERY_KEY\": {\n \"description\": \"Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)\",\n \"prompt\": \"Matrix recovery key\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"BLUEBUBBLES_SERVER_URL\": {\n \"description\": \"BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)\",\n \"prompt\": \"BlueBubbles server URL\",\n \"url\": \"https://bluebubbles.app/\",\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_PASSWORD\": {\n \"description\": \"BlueBubbles server password (from BlueBubbles Server → Settings → API)\",\n \"prompt\": \"BlueBubbles server password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOWED_USERS\": {\n \"description\": \"Comma-separated iMessage addresses (email or phone) allowed to use the bot\",\n \"prompt\": \"Allowed iMessage addresses (comma-separated)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"BLUEBUBBLES_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all BlueBubbles users without allowlist\",\n \"prompt\": \"Allow All BlueBubbles Users\",\n \"category\": \"messaging\",\n },\n \"QQ_APP_ID\": {\n \"description\": \"QQ Bot App ID from QQ Open Platform (q.qq.com)\",\n \"prompt\": \"QQ App ID\",\n \"url\": \"https://q.qq.com\",\n \"category\": \"messaging\",\n },\n \"QQ_CLIENT_SECRET\": {\n \"description\": \"QQ Bot Client Secret from QQ Open Platform\",\n \"prompt\": \"QQ Client Secret\",\n \"password\": True,\n \"category\": \"messaging\",\n },\n \"QQ_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ user IDs allowed to use the bot\",\n \"prompt\": \"QQ Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_GROUP_ALLOWED_USERS\": {\n \"description\": \"Comma-separated QQ group IDs allowed to interact with the bot\",\n \"prompt\": \"QQ Group Allowed Users\",\n \"category\": \"messaging\",\n },\n \"QQ_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all QQ users without an allowlist (true/false)\",\n \"prompt\": \"Allow All QQ Users\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL\": {\n \"description\": \"Default QQ channel/group for cron delivery and notifications\",\n \"prompt\": \"QQ Home Channel\",\n \"category\": \"messaging\",\n },\n \"QQ_HOME_CHANNEL_NAME\": {\n \"description\": \"Display name for the QQ home channel\",\n \"prompt\": \"QQ Home Channel Name\",\n \"category\": \"messaging\",\n },\n \"QQ_SANDBOX\": {\n \"description\": \"Enable QQ sandbox mode for development testing (true/false)\",\n \"prompt\": \"QQ Sandbox Mode\",\n \"category\": \"messaging\",\n },\n \"GATEWAY_ALLOW_ALL_USERS\": {\n \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n \"prompt\": \"Allow all users (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_ENABLED\": {\n \"description\": \"Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.\",\n \"prompt\": \"Enable API server (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_KEY\": {\n \"description\": \"Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.\",\n \"prompt\": \"API server auth key (required for network access)\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_PORT\": {\n \"description\": \"Port for the API server (default: 8642).\",\n \"prompt\": \"API server port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_HOST\": {\n \"description\": \"Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.\",\n \"prompt\": \"API server host\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"API_SERVER_MODEL_NAME\": {\n \"description\": \"Model name advertised on /v1/models. Defaults to the profile name (or 'spark-agent' for the default profile). Useful for multi-user setups with OpenWebUI.\",\n \"prompt\": \"API server model name\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n \"WEBHOOK_ENABLED\": {\n \"description\": \"Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.\",\n \"prompt\": \"Enable webhooks (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_PORT\": {\n \"description\": \"Port for the webhook HTTP server (default: 8644).\",\n \"prompt\": \"Webhook port\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n },\n \"WEBHOOK_SECRET\": {\n \"description\": \"Global HMAC secret for webhook signature validation (overridable per route in config.yaml).\",\n \"prompt\": \"Webhook secret\",\n \"url\": None,\n \"password\": True,\n \"category\": \"messaging\",\n },\n # ── Agent settings ──\n \"MESSAGING_CWD\": {\n \"description\": \"Working directory for terminal commands via messaging\",\n \"prompt\": \"Messaging working directory (default: Spark workspace)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SUDO_PASSWORD\": {\n \"description\": \"Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting\",\n \"prompt\": \"Sudo password\",\n \"url\": None,\n \"password\": True,\n \"category\": \"setting\",\n },\n \"SPARK_MAX_ITERATIONS\": {\n \"description\": \"Maximum tool-calling iterations per conversation (default: 90)\",\n \"prompt\": \"Max iterations\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n # SPARK_TOOL_PROGRESS and SPARK_TOOL_PROGRESS_MODE are deprecated —\n # now configured via display.tool_progress in config.yaml (off|new|all|verbose).\n # Gateway falls back to these env vars for backward compatibility.\n \"SPARK_TOOL_PROGRESS\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Tool progress (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_TOOL_PROGRESS_MODE\": {\n \"description\": \"(deprecated) Use display.tool_progress in config.yaml instead\",\n \"prompt\": \"Progress mode (deprecated — use config.yaml)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_PREFILL_MESSAGES_FILE\": {\n \"description\": \"Path to JSON file with ephemeral prefill messages for few-shot priming\",\n \"prompt\": \"Prefill messages file path\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n \"SPARK_EPHEMERAL_SYSTEM_PROMPT\": {\n \"description\": \"Ephemeral system prompt injected at API-call time (never persisted to sessions)\",\n \"prompt\": \"Ephemeral system prompt\",\n \"url\": None,\n \"password\": False,\n \"category\": \"setting\",\n },\n}\n\n# Advisory per-platform enable/disable toggles persisted by the Messaging\n# settings UI (see gateway/platform_fields.py). Registered here so\n# reload_env() tracks them (sync + removal). WHATSAPP_ENABLED,\n# WEBHOOK_ENABLED, and API_SERVER_ENABLED are declared individually above.\nfor _flag, _platform_name in (\n (\"TELEGRAM_ENABLED\", \"Telegram\"),\n (\"DISCORD_ENABLED\", \"Discord\"),\n (\"SLACK_ENABLED\", \"Slack\"),\n (\"MATTERMOST_ENABLED\", \"Mattermost\"),\n (\"MATRIX_ENABLED\", \"Matrix\"),\n (\"SIGNAL_ENABLED\", \"Signal\"),\n (\"BLUEBUBBLES_ENABLED\", \"BlueBubbles (iMessage)\"),\n (\"HOMEASSISTANT_ENABLED\", \"Home Assistant\"),\n (\"EMAIL_ENABLED\", \"Email\"),\n (\"SMS_ENABLED\", \"SMS\"),\n (\"DINGTALK_ENABLED\", \"DingTalk\"),\n (\"FEISHU_ENABLED\", \"Feishu/Lark\"),\n (\"WECOM_ENABLED\", \"WeCom\"),\n (\"WECOM_CALLBACK_ENABLED\", \"WeCom Callback\"),\n (\"WEIXIN_ENABLED\", \"WeChat Official Account\"),\n (\"QQBOT_ENABLED\", \"QQ Bot\"),\n):\n OPTIONAL_ENV_VARS.setdefault(\n _flag,\n {\n \"description\": (\n f\"Enable/disable the {_platform_name} messaging platform \"\n \"(true/false). Auto-managed when credentials are saved in \"\n \"Messaging > Settings.\"\n ),\n \"prompt\": f\"Enable {_platform_name} (true/false)\",\n \"url\": None,\n \"password\": False,\n \"category\": \"messaging\",\n \"advanced\": True,\n },\n )\n\nfor _hidden_var in (\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n):\n OPTIONAL_ENV_VARS.pop(_hidden_var, None)\n\n\ndef get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:\n \"\"\"\n Check which environment variables are missing.\n\n Returns list of dicts with var info for missing variables.\n \"\"\"\n missing = []\n\n # Check required vars\n for var_name, info in REQUIRED_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": True})\n\n # Check optional vars (if not required_only)\n if not required_only:\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if not get_env_value(var_name):\n missing.append({\"name\": var_name, **info, \"is_required\": False})\n\n return missing\n\n\ndef _set_nested(config: dict, dotted_key: str, value):\n \"\"\"Set a value at an arbitrarily nested dotted key path.\n\n Creates intermediate dicts as needed, e.g. ``_set_nested(c, \"a.b.c\", 1)``\n ensures ``c[\"a\"][\"b\"][\"c\"] == 1``.\n \"\"\"\n parts = dotted_key.split(\".\")\n current = config\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n current[parts[-1]] = value\n\n\ndef get_missing_config_fields() -> List[Dict[str, Any]]:\n \"\"\"\n Check which config fields are missing or outdated (recursive).\n\n Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys\n present in defaults but absent from the user's loaded config.\n \"\"\"\n config = load_config()\n missing = []\n\n def _check(defaults: dict, current: dict, prefix: str = \"\"):\n for key, default_value in defaults.items():\n if key.startswith(\"_\"):\n continue\n full_key = key if not prefix else f\"{prefix}.{key}\"\n if key not in current:\n missing.append(\n {\n \"key\": full_key,\n \"default\": default_value,\n \"description\": f\"New config option: {full_key}\",\n }\n )\n elif isinstance(default_value, dict) and isinstance(current.get(key), dict):\n _check(default_value, current[key], full_key)\n\n _check(DEFAULT_CONFIG, config)\n return missing\n\n\ndef get_missing_skill_config_vars() -> List[Dict[str, Any]]:\n \"\"\"Return skill-declared config vars that are missing or empty in config.yaml.\n\n Scans all enabled skills for ``metadata.spark.config`` entries, then checks\n which ones are absent or empty under ``skills.config.<key>`` in the user's\n config.yaml. Returns a list of dicts suitable for prompting.\n \"\"\"\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n SKILL_CONFIG_PREFIX,\n )\n except Exception:\n return []\n\n all_vars = discover_all_skill_config_vars()\n if not all_vars:\n return []\n\n config = load_config()\n missing: List[Dict[str, Any]] = []\n for var in all_vars:\n # Skill config is stored under skills.config.<logical_key>\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n parts = storage_key.split(\".\")\n current = config\n value = None\n for part in parts:\n if isinstance(current, dict) and part in current:\n current = current[part]\n value = current\n else:\n value = None\n break\n # Missing = key doesn't exist or is empty string\n if value is None or (isinstance(value, str) and not value.strip()):\n missing.append(var)\n return missing\n\n\ndef _normalize_custom_provider_entry(\n entry: Any,\n *,\n provider_key: str = \"\",\n) -> Optional[Dict[str, Any]]:\n \"\"\"Return a runtime-compatible custom provider entry or ``None``.\"\"\"\n if not isinstance(entry, dict):\n return None\n\n base_url = \"\"\n for url_key in (\"api\", \"url\", \"base_url\"):\n raw_url = entry.get(url_key)\n if isinstance(raw_url, str) and raw_url.strip():\n base_url = raw_url.strip()\n break\n if not base_url:\n return None\n\n name = \"\"\n raw_name = entry.get(\"name\")\n if isinstance(raw_name, str) and raw_name.strip():\n name = raw_name.strip()\n elif provider_key.strip():\n name = provider_key.strip()\n if not name:\n return None\n\n normalized: Dict[str, Any] = {\n \"name\": name,\n \"base_url\": base_url,\n }\n\n provider_key = provider_key.strip()\n if provider_key:\n normalized[\"provider_key\"] = provider_key\n\n api_key = entry.get(\"api_key\")\n if isinstance(api_key, str) and api_key.strip():\n normalized[\"api_key\"] = api_key.strip()\n\n key_env = entry.get(\"key_env\")\n if isinstance(key_env, str) and key_env.strip():\n normalized[\"key_env\"] = key_env.strip()\n\n api_mode = entry.get(\"api_mode\") or entry.get(\"transport\")\n if isinstance(api_mode, str) and api_mode.strip():\n normalized[\"api_mode\"] = api_mode.strip()\n\n model_name = entry.get(\"model\") or entry.get(\"default_model\")\n if isinstance(model_name, str) and model_name.strip():\n normalized[\"model\"] = model_name.strip()\n\n models = entry.get(\"models\")\n if isinstance(models, (dict, list)) and models:\n normalized[\"models\"] = models\n\n context_length = entry.get(\"context_length\")\n if isinstance(context_length, int) and context_length > 0:\n normalized[\"context_length\"] = context_length\n\n rate_limit_delay = entry.get(\"rate_limit_delay\")\n if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:\n normalized[\"rate_limit_delay\"] = rate_limit_delay\n\n return normalized\n\n\ndef providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:\n \"\"\"Normalize ``providers`` config entries into the legacy custom-provider shape.\"\"\"\n if not isinstance(providers_dict, dict):\n return []\n\n custom_providers: List[Dict[str, Any]] = []\n for key, entry in providers_dict.items():\n normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))\n if normalized is not None:\n custom_providers.append(normalized)\n\n return custom_providers\n\n\ndef get_compatible_custom_providers(\n config: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n \"\"\"Return a deduplicated custom-provider view across legacy and v12+ config.\n\n ``custom_providers`` remains the on-disk legacy format, while ``providers``\n is the newer keyed schema. Runtime and picker flows still need a single\n list-shaped view, but we should not materialise that compatibility layer\n back into config.yaml because it duplicates entries in UIs.\n \"\"\"\n if config is None:\n config = load_config()\n\n compatible: List[Dict[str, Any]] = []\n seen_provider_keys: set = set()\n seen_name_url_pairs: set = set()\n\n def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n if entry is None:\n return\n provider_key = str(entry.get(\"provider_key\", \"\") or \"\").strip().lower()\n name = str(entry.get(\"name\", \"\") or \"\").strip().lower()\n base_url = str(entry.get(\"base_url\", \"\") or \"\").strip().rstrip(\"/\").lower()\n model = str(entry.get(\"model\", \"\") or \"\").strip().lower()\n pair = (name, base_url, model)\n\n if provider_key and provider_key in seen_provider_keys:\n return\n if name and base_url and pair in seen_name_url_pairs:\n return\n\n compatible.append(entry)\n if provider_key:\n seen_provider_keys.add(provider_key)\n if name and base_url:\n seen_name_url_pairs.add(pair)\n\n custom_providers = config.get(\"custom_providers\")\n if custom_providers is not None:\n if not isinstance(custom_providers, list):\n return []\n for entry in custom_providers:\n _append_if_new(_normalize_custom_provider_entry(entry))\n\n for entry in providers_dict_to_custom_providers(config.get(\"providers\")):\n _append_if_new(entry)\n\n return compatible\n\n\ndef check_config_version() -> Tuple[int, int]:\n \"\"\"\n Check config version.\n\n Returns (current_version, latest_version).\n \"\"\"\n config = load_config()\n current = config.get(\"_config_version\", 0)\n latest = DEFAULT_CONFIG.get(\"_config_version\", 1)\n return current, latest\n\n\n# =============================================================================\n# Config structure validation\n# =============================================================================\n\n# Fields that are valid at root level of config.yaml\n_KNOWN_ROOT_KEYS = {\n \"_config_version\",\n \"model\",\n \"providers\",\n \"fallback_model\",\n \"fallback_providers\",\n \"credential_pool_strategies\",\n \"toolsets\",\n \"smart_model_routing\",\n \"agent\",\n \"terminal\",\n \"display\",\n \"compression\",\n \"delegation\",\n \"auxiliary\",\n \"custom_providers\",\n \"context\",\n \"memory\",\n \"gateway\",\n}\n\n# Valid fields inside a custom_providers list entry\n_VALID_CUSTOM_PROVIDER_FIELDS = {\n \"name\",\n \"base_url\",\n \"url\",\n \"api\",\n \"api_key\",\n \"key_env\",\n \"api_mode\",\n \"transport\",\n \"default_model\",\n \"model\",\n \"models\",\n \"context_length\",\n \"rate_limit_delay\",\n}\n\n# Fields that look like they should be inside custom_providers, not at root\n_CUSTOM_PROVIDER_LIKE_FIELDS = {\"base_url\", \"api_key\", \"rate_limit_delay\", \"api_mode\"}\n_VALID_PROVIDER_TRANSPORTS = {\n \"openai_chat\",\n \"chat_completions\",\n \"anthropic_messages\",\n \"codex_responses\",\n \"responses\",\n}\n\n\n@dataclass\nclass ConfigIssue:\n \"\"\"A detected config structure problem.\"\"\"\n\n severity: str # \"error\", \"warning\"\n message: str\n hint: str\n\n\ndef validate_config_structure(\n config: Optional[Dict[str, Any]] = None,\n) -> List[\"ConfigIssue\"]:\n \"\"\"Validate config.yaml structure and return a list of detected issues.\n\n Catches common YAML formatting mistakes that produce confusing runtime\n errors (like \"Unknown provider\") instead of clear diagnostics.\n\n Can be called with a pre-loaded config dict, or will load from disk.\n \"\"\"\n if config is None:\n try:\n config = load_config()\n except Exception:\n return [\n ConfigIssue(\n \"error\",\n \"Could not load config.yaml\",\n \"Run 'spark setup' to create a valid config\",\n )\n ]\n\n issues: List[ConfigIssue] = []\n\n # ── providers must be a keyed dict of OpenAI-compatible endpoints ─────\n providers_cfg = config.get(\"providers\")\n if providers_cfg is not None:\n if not isinstance(providers_cfg, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"providers should be a dict keyed by provider id, \"\n f\"got {type(providers_cfg).__name__}\",\n \"Change to:\\n\"\n \" providers:\\n\"\n \" local-llm:\\n\"\n \" api: http://localhost:11434/v1\\n\"\n \" default_model: llama3\",\n )\n )\n else:\n for provider_key, entry in providers_cfg.items():\n label = f\"providers.{provider_key}\"\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label} is not a dict (got {type(entry).__name__})\",\n \"Each providers entry should contain at minimum an \"\n \"api/base_url and optional models.\",\n )\n )\n continue\n api_url = (\n entry.get(\"api\") or entry.get(\"url\") or entry.get(\"base_url\") or \"\"\n )\n if not isinstance(api_url, str) or not api_url.strip():\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label} is missing an api/url/base_url endpoint\",\n \"Add an OpenAI-compatible endpoint, e.g. api: http://localhost:11434/v1\",\n )\n )\n key_env = entry.get(\"key_env\")\n if key_env is not None and (\n not isinstance(key_env, str)\n or not _ENV_VAR_NAME_RE.match(key_env.strip())\n ):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.key_env is not a valid environment variable name\",\n \"Use only letters, numbers, and underscores, starting with a letter or underscore.\",\n )\n )\n transport = entry.get(\"transport\", entry.get(\"api_mode\"))\n if transport is not None:\n transport_value = str(transport or \"\").strip().lower()\n if transport_value and transport_value not in _VALID_PROVIDER_TRANSPORTS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.transport has unsupported value '{transport}'\",\n \"Use openai_chat, chat_completions, \"\n \"anthropic_messages, or codex_responses.\",\n )\n )\n models = entry.get(\"models\")\n if models is not None:\n valid_models = isinstance(models, list) and all(\n isinstance(m, str) and m.strip() for m in models\n )\n valid_model_meta = isinstance(models, dict)\n if not (valid_models or valid_model_meta):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"{label}.models should be a list of model ids\",\n \"Example: models: [llama3, qwen3-coder]\",\n )\n )\n\n # ── custom_providers must be a list, not a dict ──────────────────────\n cp = config.get(\"custom_providers\")\n if cp is not None:\n if isinstance(cp, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n \"custom_providers is a dict — it must be a YAML list (items prefixed with '-')\",\n \"Change to:\\n\"\n \" custom_providers:\\n\"\n \" - name: my-provider\\n\"\n \" base_url: https://...\\n\"\n \" api_key: ...\",\n )\n )\n # Check if dict keys look like they should be list-entry fields\n cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()\n suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS\n if suspicious:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level keys {sorted(suspicious)} look like custom_providers entry fields\",\n \"These should be indented under a '- name: ...' list entry, not at root level\",\n )\n )\n elif isinstance(cp, list):\n # Validate each entry in the list\n for i, entry in enumerate(cp):\n if not isinstance(entry, dict):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is not a dict (got {type(entry).__name__})\",\n \"Each entry should have at minimum: name, base_url\",\n )\n )\n continue\n if not entry.get(\"name\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'name' field\",\n \"Add a name, e.g.: name: my-provider\",\n )\n )\n if not entry.get(\"base_url\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"custom_providers[{i}] is missing 'base_url' field\",\n \"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1\",\n )\n )\n\n # ── fallback_model must be a top-level dict with provider + model ────\n fb = config.get(\"fallback_model\")\n if fb is not None:\n if not isinstance(fb, dict):\n issues.append(\n ConfigIssue(\n \"error\",\n f\"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}\",\n \"Change to:\\n\"\n \" fallback_model:\\n\"\n \" provider: openrouter\\n\"\n \" model: anthropic/claude-sonnet-4\",\n )\n )\n elif fb:\n if not fb.get(\"provider\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'provider' field — fallback will be disabled\",\n \"Add: provider: openrouter (or another provider)\",\n )\n )\n if not fb.get(\"model\"):\n issues.append(\n ConfigIssue(\n \"warning\",\n \"fallback_model is missing 'model' field — fallback will be disabled\",\n \"Add: model: anthropic/claude-sonnet-4 (or another model)\",\n )\n )\n\n # ── Check for fallback_model accidentally nested inside custom_providers ──\n if (\n isinstance(cp, dict)\n and \"fallback_model\" not in config\n and \"fallback_model\" in (cp or {})\n ):\n issues.append(\n ConfigIssue(\n \"error\",\n \"fallback_model appears inside custom_providers instead of at root level\",\n \"Move fallback_model to the top level of config.yaml (no indentation)\",\n )\n )\n\n # ── model section: should exist when custom_providers is configured ──\n model_cfg = config.get(\"model\")\n if cp and not model_cfg:\n issues.append(\n ConfigIssue(\n \"warning\",\n \"custom_providers defined but no 'model' section — Spark won't know which provider to use\",\n \"Add a model section:\\n\"\n \" model:\\n\"\n \" provider: custom\\n\"\n \" default: your-model-name\\n\"\n \" base_url: https://...\",\n )\n )\n\n # ── Root-level keys that look misplaced ──────────────────────────────\n for key in config:\n if key.startswith(\"_\"):\n continue\n if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:\n issues.append(\n ConfigIssue(\n \"warning\",\n f\"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?\",\n f\"Move '{key}' under the appropriate section\",\n )\n )\n\n return issues\n\n\ndef print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:\n \"\"\"Print config structure warnings to stderr at startup.\n\n Called early in CLI and gateway init so users see problems before\n they hit cryptic \"Unknown provider\" errors. Prints nothing if\n config is healthy.\n \"\"\"\n try:\n issues = validate_config_structure(config)\n except Exception:\n return\n if not issues:\n return\n\n import sys\n\n lines = [\"\\033[33m⚠ Config issues detected in config.yaml:\\033[0m\"]\n for ci in issues:\n marker = \"\\033[31m✗\\033[0m\" if ci.severity == \"error\" else \"\\033[33m⚠\\033[0m\"\n lines.append(f\" {marker} {ci.message}\")\n lines.append(\" \\033[2mRun 'spark doctor' for fix suggestions.\\033[0m\")\n sys.stderr.write(\"\\n\".join(lines) + \"\\n\\n\")\n\n\ndef migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:\n \"\"\"\n Migrate config to latest version, prompting for new required fields.\n\n Args:\n interactive: If True, prompt user for missing values\n quiet: If True, suppress output\n\n Returns:\n Dict with migration results: {\"env_added\": [...], \"config_added\": [...], \"warnings\": [...]}\n \"\"\"\n results = {\"env_added\": [], \"config_added\": [], \"warnings\": []}\n\n # ── Always: sanitize .env (split concatenated keys) ──\n try:\n fixes = sanitize_env_file()\n if fixes and not quiet:\n print(f\" ✓ Repaired .env file ({fixes} corrupted entries fixed)\")\n except Exception:\n pass # best-effort; don't block migration on sanitize failure\n\n # Check config version\n current_ver, latest_ver = check_config_version()\n\n # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──\n if current_ver < 4:\n config = load_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"tool_progress\" not in display:\n old_enabled = get_env_value(\"SPARK_TOOL_PROGRESS\")\n old_mode = get_env_value(\"SPARK_TOOL_PROGRESS_MODE\")\n if old_enabled and old_enabled.lower() in (\"false\", \"0\", \"no\"):\n display[\"tool_progress\"] = \"off\"\n results[\"config_added\"].append(\n \"display.tool_progress=off (from SPARK_TOOL_PROGRESS=false)\"\n )\n elif old_mode and old_mode.lower() in (\"new\", \"all\"):\n display[\"tool_progress\"] = old_mode.lower()\n results[\"config_added\"].append(\n f\"display.tool_progress={old_mode.lower()} (from SPARK_TOOL_PROGRESS_MODE)\"\n )\n else:\n display[\"tool_progress\"] = \"all\"\n results[\"config_added\"].append(\"display.tool_progress=all (default)\")\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}\"\n )\n\n # ── Version 4 → 5: add timezone field ──\n if current_ver < 5:\n config = load_config()\n if \"timezone\" not in config:\n old_tz = os.getenv(\"SPARK_TIMEZONE\", \"\")\n if old_tz and old_tz.strip():\n config[\"timezone\"] = old_tz.strip()\n results[\"config_added\"].append(\n f\"timezone={old_tz.strip()} (from SPARK_TIMEZONE)\"\n )\n else:\n config[\"timezone\"] = \"\"\n results[\"config_added\"].append(\"timezone= (empty, uses server-local)\")\n save_config(config)\n if not quiet:\n tz_display = config[\"timezone\"] or \"(server-local)\"\n print(f\" ✓ Added timezone to config.yaml: {tz_display}\")\n\n # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──\n # The new Anthropic auth flow no longer uses this env var.\n if current_ver < 9:\n try:\n old_token = get_env_value(\"ANTHROPIC_TOKEN\")\n if old_token:\n save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n if not quiet:\n print(\" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)\")\n except Exception:\n pass\n\n # ── Version 11 → 12: migrate custom_providers list → providers dict ──\n if current_ver < 12:\n config = load_config()\n custom_list = config.get(\"custom_providers\")\n if isinstance(custom_list, list) and custom_list:\n providers_dict = config.get(\"providers\", {})\n if not isinstance(providers_dict, dict):\n providers_dict = {}\n migrated_count = 0\n for entry in custom_list:\n if not isinstance(entry, dict):\n continue\n old_name = entry.get(\"name\", \"\")\n old_url = entry.get(\"base_url\", \"\") or entry.get(\"url\", \"\") or \"\"\n old_key = entry.get(\"api_key\", \"\")\n if not old_url:\n continue # skip entries with no URL\n\n # Generate a kebab-case key from the display name\n key = (\n old_name.strip()\n .lower()\n .replace(\" \", \"-\")\n .replace(\"(\", \"\")\n .replace(\")\", \"\")\n )\n # Remove consecutive hyphens and trailing hyphens\n while \"--\" in key:\n key = key.replace(\"--\", \"-\")\n key = key.strip(\"-\")\n if not key:\n # Fallback: derive from URL hostname\n try:\n from urllib.parse import urlparse\n\n parsed = urlparse(old_url)\n key = (parsed.hostname or \"endpoint\").replace(\".\", \"-\")\n except Exception:\n key = f\"endpoint-{migrated_count}\"\n\n # Don't overwrite existing entries\n if key in providers_dict:\n key = f\"{key}-{migrated_count}\"\n\n new_entry = {\"api\": old_url}\n if old_name:\n new_entry[\"name\"] = old_name\n if old_key and old_key not in (\"no-key\", \"no-key-required\", \"\"):\n new_entry[\"api_key\"] = old_key\n\n # Carry over model and api_mode if present\n if entry.get(\"model\"):\n new_entry[\"default_model\"] = entry[\"model\"]\n if entry.get(\"api_mode\"):\n new_entry[\"transport\"] = entry[\"api_mode\"]\n\n providers_dict[key] = new_entry\n migrated_count += 1\n\n if migrated_count > 0:\n config[\"providers\"] = providers_dict\n # Remove the old list — runtime reads via get_compatible_custom_providers()\n config.pop(\"custom_providers\", None)\n save_config(config)\n if not quiet:\n print(\n f\" ✓ Migrated {migrated_count} custom provider(s) to providers: section\"\n )\n for key in list(providers_dict.keys())[-migrated_count:]:\n ep = providers_dict[key]\n print(f\" → {key}: {ep.get('api', '')}\")\n\n # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──\n # These env vars were written by the old setup wizard but nothing reads\n # them anymore (config.yaml is the sole source of truth since March 2026).\n # Stale entries cause user confusion — see issue report.\n if current_ver < 13:\n for dead_var in (\"LLM_MODEL\", \"OPENAI_MODEL\"):\n try:\n old_val = get_env_value(dead_var)\n if old_val:\n save_env_value(dead_var, \"\")\n if not quiet:\n print(\n f\" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)\"\n )\n except Exception:\n pass\n\n # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──\n # Old configs (and cli-config.yaml.example) had a flat `stt.model` key\n # that was provider-agnostic. When the provider was \"local\" this caused\n # OpenAI model names (e.g. \"whisper-1\") to be fed to faster-whisper,\n # crashing with \"Invalid model size\". Move the value into the correct\n # provider-specific section and remove the flat key.\n if current_ver < 14:\n # Read raw config (no defaults merged) to check what the user actually\n # wrote, then apply changes to the merged config for saving.\n raw = read_raw_config()\n raw_stt = raw.get(\"stt\", {})\n if isinstance(raw_stt, dict) and \"model\" in raw_stt:\n legacy_model = raw_stt[\"model\"]\n provider = raw_stt.get(\"provider\", \"local\")\n config = load_config()\n stt = config.get(\"stt\", {})\n # Remove the legacy flat key\n stt.pop(\"model\", None)\n # Place it in the appropriate provider section only if the\n # user didn't already set a model there\n if provider in (\"local\", \"local_command\"):\n # Don't migrate an OpenAI model name into the local section\n _local_models = {\n \"tiny.en\",\n \"tiny\",\n \"base.en\",\n \"base\",\n \"small.en\",\n \"small\",\n \"medium.en\",\n \"medium\",\n \"large-v1\",\n \"large-v2\",\n \"large-v3\",\n \"large\",\n \"distil-large-v2\",\n \"distil-medium.en\",\n \"distil-small.en\",\n \"distil-large-v3\",\n \"distil-large-v3.5\",\n \"large-v3-turbo\",\n \"turbo\",\n }\n if legacy_model in _local_models:\n # Check raw config — only set if user didn't already\n # have a nested local.model\n raw_local = raw_stt.get(\"local\", {})\n if not isinstance(raw_local, dict) or \"model\" not in raw_local:\n local_cfg = stt.setdefault(\"local\", {})\n local_cfg[\"model\"] = legacy_model\n # else: drop it — it was an OpenAI model name, local section\n # already defaults to \"base\" via DEFAULT_CONFIG\n else:\n # Cloud provider — put it in that provider's section only\n # if user didn't already set a nested model\n raw_provider = raw_stt.get(provider, {})\n if not isinstance(raw_provider, dict) or \"model\" not in raw_provider:\n provider_cfg = stt.setdefault(provider, {})\n provider_cfg[\"model\"] = legacy_model\n config[\"stt\"] = stt\n save_config(config)\n if not quiet:\n print(\" ✓ Migrated legacy stt.model to provider-specific config\")\n\n # ── Version 14 → 15: add explicit gateway interim-message gate ──\n if current_ver < 15:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"interim_assistant_messages\" not in display:\n display[\"interim_assistant_messages\"] = True\n config[\"display\"] = display\n results[\"config_added\"].append(\n \"display.interim_assistant_messages=true (default)\"\n )\n save_config(config)\n if not quiet:\n print(\" ✓ Added display.interim_assistant_messages=true\")\n\n # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──\n if current_ver < 16:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n old_overrides = display.get(\"tool_progress_overrides\")\n if isinstance(old_overrides, dict) and old_overrides:\n platforms = display.get(\"platforms\", {})\n if not isinstance(platforms, dict):\n platforms = {}\n for plat, mode in old_overrides.items():\n if plat not in platforms:\n platforms[plat] = {}\n if \"tool_progress\" not in platforms[plat]:\n platforms[plat][\"tool_progress\"] = mode\n display[\"platforms\"] = platforms\n config[\"display\"] = display\n save_config(config)\n if not quiet:\n migrated = \", \".join(f\"{p}={m}\" for p, m in old_overrides.items())\n print(\n f\" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}\"\n )\n results[\"config_added\"].append(\n \"display.platforms (migrated from tool_progress_overrides)\"\n )\n\n # ── Version 16 → 17: remove legacy compression.summary_* keys ──\n if current_ver < 17:\n config = read_raw_config()\n comp = config.get(\"compression\", {})\n if isinstance(comp, dict):\n s_model = comp.pop(\"summary_model\", None)\n s_provider = comp.pop(\"summary_provider\", None)\n s_base_url = comp.pop(\"summary_base_url\", None)\n migrated_keys = []\n # Migrate non-empty, non-default values to auxiliary.compression\n if s_model and str(s_model).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"model\"):\n aux_comp[\"model\"] = str(s_model).strip()\n migrated_keys.append(f\"model={s_model}\")\n if s_provider and str(s_provider).strip() not in (\"\", \"auto\"):\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"provider\") or aux_comp.get(\"provider\") == \"auto\":\n aux_comp[\"provider\"] = str(s_provider).strip()\n migrated_keys.append(f\"provider={s_provider}\")\n if s_base_url and str(s_base_url).strip():\n aux = config.setdefault(\"auxiliary\", {})\n aux_comp = aux.setdefault(\"compression\", {})\n if not aux_comp.get(\"base_url\"):\n aux_comp[\"base_url\"] = str(s_base_url).strip()\n migrated_keys.append(f\"base_url={s_base_url}\")\n if (\n migrated_keys\n or s_model is not None\n or s_provider is not None\n or s_base_url is not None\n ):\n config[\"compression\"] = comp\n save_config(config)\n if not quiet:\n if migrated_keys:\n print(\n f\" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}\"\n )\n else:\n print(\" ✓ Removed unused compression.summary_* keys\")\n\n # ── Version 17 → 18: add dashboard + kanban defaults ──\n if current_ver < 18:\n config = read_raw_config()\n changed = False\n if \"dashboard\" not in config:\n config[\"dashboard\"] = {\n \"enabled_with_gateway\": True,\n \"host\": \"0.0.0.0\",\n \"port\": 9119,\n \"require_auth_nonlocal\": True,\n \"public_url\": \"\",\n }\n changed = True\n elif \"public_url\" not in config.get(\"dashboard\", {}):\n config.setdefault(\"dashboard\", {})[\"public_url\"] = \"\"\n changed = True\n if \"kanban\" not in config:\n config[\"kanban\"] = {\n \"dispatch_in_gateway\": False,\n \"dispatch_interval_seconds\": 60,\n \"workflow_in_gateway\": False,\n \"workflow_interval_seconds\": 10,\n \"notify_on_changes\": False,\n \"wake_creator_on_changes\": False,\n \"failure_limit\": 5,\n \"claim_ttl_seconds\": 3600,\n \"max_runtime_seconds\": 0,\n \"default_board\": \"default\",\n }\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added dashboard.* and kanban.* sections\")\n\n # -- Version 26 -> 27: add Kanban notification/wake workflow defaults --\n if current_ver < 27:\n config = read_raw_config()\n kanban = config.setdefault(\"kanban\", {})\n changed = False\n if isinstance(kanban, dict):\n for key, value in {\n \"workflow_in_gateway\": False,\n \"workflow_interval_seconds\": 10,\n \"notify_on_changes\": False,\n \"wake_creator_on_changes\": False,\n }.items():\n if key not in kanban:\n kanban[key] = value\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Added kanban workflow notification/wake defaults\")\n\n # ── Version 20 → 21: backfill clarify into explicit CLI platform toolsets ──\n if current_ver < 21:\n config = read_raw_config()\n platform_toolsets = config.get(\"platform_toolsets\", {})\n # Platforms that intentionally exclude clarify (editor/gateway environments)\n _no_clarify_platforms = {\"editor\", \"gateway\", \"singularity\", \"docker\"}\n changed = False\n for platform, toolsets in platform_toolsets.items():\n if (\n isinstance(toolsets, list)\n and \"clarify\" not in toolsets\n and platform not in _no_clarify_platforms\n ):\n toolsets.append(\"clarify\")\n toolsets.sort()\n changed = True\n if changed:\n save_config(config)\n if not quiet:\n print(\" ✓ Backfilled 'clarify' toolset into existing platform config\")\n\n # ── Version 22 → 23: add display.preview_browser_backend (WebUI browser pane) ──\n if current_ver < 23:\n config = read_raw_config()\n display = config.get(\"display\", {})\n if not isinstance(display, dict):\n display = {}\n if \"preview_browser_backend\" not in display:\n display[\"preview_browser_backend\"] = \"auto\"\n config[\"display\"] = display\n save_config(config)\n results[\"config_added\"].append(\"display.preview_browser_backend=auto (default)\")\n if not quiet:\n print(\" ✓ Added display.preview_browser_backend=auto\")\n\n if current_ver < latest_ver and not quiet:\n print(f\"Config version: {current_ver} → {latest_ver}\")\n\n # Check for missing required env vars\n missing_env = get_missing_env_vars(required_only=True)\n\n if missing_env and not quiet:\n print(\"\\n⚠️ Missing required environment variables:\")\n for var in missing_env:\n print(f\" • {var['name']}: {var['description']}\")\n\n if interactive and missing_env:\n print(\"\\nLet's configure them now:\\n\")\n for var in missing_env:\n if var.get(\"url\"):\n print(f\" Get your key at: {var['url']}\")\n\n if var.get(\"password\"):\n import getpass\n\n value = getpass.getpass(f\" {var['prompt']}: \")\n else:\n value = input(f\" {var['prompt']}: \").strip()\n\n if value:\n save_env_value(var[\"name\"], value)\n results[\"env_added\"].append(var[\"name\"])\n print(f\" ✓ Saved {var['name']}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['name']} - some features may not work\"\n )\n print()\n\n # Check for missing optional env vars and offer to configure interactively\n # Skip \"advanced\" vars (like OPENAI_BASE_URL) -- those are for power users\n missing_optional = get_missing_env_vars(required_only=False)\n required_names = {v[\"name\"] for v in missing_env} if missing_env else set()\n missing_optional = [\n v\n for v in missing_optional\n if v[\"name\"] not in required_names and not v.get(\"advanced\")\n ]\n\n # Only offer to configure env vars that are NEW since the user's previous version\n new_var_names = set()\n for ver in range(current_ver + 1, latest_ver + 1):\n new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))\n\n if new_var_names and interactive and not quiet:\n new_and_unset = [\n (name, OPTIONAL_ENV_VARS[name])\n for name in sorted(new_var_names)\n if not get_env_value(name) and name in OPTIONAL_ENV_VARS\n ]\n if new_and_unset:\n print(f\"\\n {len(new_and_unset)} new optional key(s) in this update:\")\n for name, info in new_and_unset:\n print(f\" • {name} — {info.get('description', '')}\")\n print()\n try:\n answer = input(\" Configure new keys? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n for name, info in new_and_unset:\n if info.get(\"url\"):\n print(f\" {info.get('description', name)}\")\n print(f\" Get your key at: {info['url']}\")\n else:\n print(f\" {info.get('description', name)}\")\n if info.get(\"password\"):\n import getpass\n\n value = getpass.getpass(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n )\n else:\n value = input(\n f\" {info.get('prompt', name)} (Enter to skip): \"\n ).strip()\n if value:\n save_env_value(name, value)\n results[\"env_added\"].append(name)\n print(f\" ✓ Saved {name}\")\n print()\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n # Check for missing config fields\n missing_config = get_missing_config_fields()\n\n if missing_config:\n config = load_config()\n\n for field in missing_config:\n key = field[\"key\"]\n default = field[\"default\"]\n\n _set_nested(config, key, default)\n results[\"config_added\"].append(key)\n if not quiet:\n print(f\" ✓ Added {key} = {default}\")\n\n # Update version and save\n config[\"_config_version\"] = latest_ver\n save_config(config)\n elif current_ver < latest_ver:\n # Just update version\n config = load_config()\n config[\"_config_version\"] = latest_ver\n save_config(config)\n\n # ── Skill-declared config vars ──────────────────────────────────────\n # Skills can declare config.yaml settings they need via\n # metadata.spark.config in their SKILL.md frontmatter.\n # Prompt for any that are missing/empty.\n missing_skill_config = get_missing_skill_config_vars()\n if missing_skill_config and interactive and not quiet:\n print(f\"\\n {len(missing_skill_config)} skill setting(s) not configured:\")\n for var in missing_skill_config:\n skill_name = var.get(\"skill\", \"unknown\")\n print(\n f\" • {var['key']} — {var['description']} (from skill: {skill_name})\"\n )\n print()\n try:\n answer = input(\" Configure skill settings? [y/N]: \").strip().lower()\n except (EOFError, KeyboardInterrupt):\n answer = \"n\"\n\n if answer in (\"y\", \"yes\"):\n print()\n config = load_config()\n try:\n from agent.skill_utils import SKILL_CONFIG_PREFIX\n except Exception:\n SKILL_CONFIG_PREFIX = \"skills.config\"\n for var in missing_skill_config:\n default = var.get(\"default\", \"\")\n default_hint = f\" (default: {default})\" if default else \"\"\n value = input(f\" {var['prompt']}{default_hint}: \").strip()\n if not value and default:\n value = str(default)\n if value:\n storage_key = f\"{SKILL_CONFIG_PREFIX}.{var['key']}\"\n _set_nested(config, storage_key, value)\n results[\"config_added\"].append(var[\"key\"])\n print(f\" ✓ Saved {var['key']} = {value}\")\n else:\n results[\"warnings\"].append(\n f\"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later\"\n )\n print()\n save_config(config)\n else:\n print(\" Set later with: spark config set <key> <value>\")\n\n return results\n\n\ndef _deep_merge(base: dict, override: dict) -> dict:\n \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n\n Keys in *override* take precedence. If both values are dicts the merge\n recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will\n keep the default ``tts.elevenlabs.model_id`` intact.\n \"\"\"\n result = base.copy()\n for key, value in override.items():\n if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n result[key] = _deep_merge(result[key], value)\n else:\n result[key] = value\n return result\n\n\ndef _expand_env_vars(obj):\n \"\"\"Recursively expand ``${VAR}`` references in config values.\n\n Only string values are processed; dict keys, numbers, booleans, and\n None are left untouched. Unresolved references (variable not in\n ``os.environ``) are kept verbatim so callers can detect them.\n \"\"\"\n if isinstance(obj, str):\n return re.sub(\n r\"\\${([^}]+)}\",\n lambda m: os.environ.get(m.group(1), m.group(0)),\n obj,\n )\n if isinstance(obj, dict):\n return {k: _expand_env_vars(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [_expand_env_vars(item) for item in obj]\n return obj\n\n\ndef _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Move stale root-level provider/base_url into model section.\n\n Some users (or older code) placed ``provider:`` and ``base_url:`` at the\n config root instead of inside ``model:``. These root-level keys are only\n used as a fallback when the corresponding ``model.*`` key is empty — they\n never override an existing ``model.provider`` or ``model.base_url``.\n After migration the root-level keys are removed so they can't cause\n confusion on subsequent loads.\n \"\"\"\n # Only act if there are root-level keys to migrate\n has_root = any(config.get(k) for k in (\"provider\", \"base_url\"))\n if not has_root:\n return config\n\n config = dict(config)\n model = config.get(\"model\")\n if not isinstance(model, dict):\n model = {\"default\": model} if model else {}\n config[\"model\"] = model\n\n for key in (\"provider\", \"base_url\"):\n root_val = config.get(key)\n if root_val and not model.get(key):\n model[key] = root_val\n config.pop(key, None)\n\n return config\n\n\ndef _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:\n \"\"\"Normalize legacy root-level max_turns into agent.max_turns.\"\"\"\n config = dict(config)\n agent_config = dict(config.get(\"agent\") or {})\n\n if \"max_turns\" in config and \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = config[\"max_turns\"]\n\n if \"max_turns\" not in agent_config:\n agent_config[\"max_turns\"] = DEFAULT_CONFIG[\"agent\"][\"max_turns\"]\n\n config[\"agent\"] = agent_config\n config.pop(\"max_turns\", None)\n return config\n\n\ndef read_raw_config() -> Dict[str, Any]:\n \"\"\"Read ~/.spark/config.yaml as-is, without merging defaults or migrating.\n\n Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't\n be parsed. Use this for lightweight config reads where you just need a\n single value and don't want the overhead of ``load_config()``'s deep-merge\n + migration pipeline.\n \"\"\"\n try:\n config_path = get_config_path()\n if config_path.exists():\n with open(config_path, encoding=\"utf-8\") as f:\n return yaml.safe_load(f) or {}\n except Exception:\n pass\n return {}\n\n\ndef load_config() -> Dict[str, Any]:\n \"\"\"Load configuration from ~/.spark/config.yaml.\"\"\"\n import copy\n\n ensure_spark_home()\n config_path = get_config_path()\n\n config = copy.deepcopy(DEFAULT_CONFIG)\n\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n\n if \"max_turns\" in user_config:\n agent_user_config = dict(user_config.get(\"agent\") or {})\n if agent_user_config.get(\"max_turns\") is None:\n agent_user_config[\"max_turns\"] = user_config[\"max_turns\"]\n user_config[\"agent\"] = agent_user_config\n user_config.pop(\"max_turns\", None)\n\n config = _deep_merge(config, user_config)\n except Exception as e:\n print(f\"Warning: Failed to load config: {e}\")\n\n return _expand_env_vars(\n _normalize_root_model_keys(_normalize_max_turns_config(config))\n )\n\n\n_SECURITY_COMMENT = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n# tirith pre-exec scanning is enabled by default when the tirith binary\n# is available. Configure via security.tirith_* keys or env vars\n# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).\n#\n# security:\n# redact_secrets: false\n# tirith_enabled: true\n# tirith_path: \"tirith\"\n# tirith_timeout: 5\n# tirith_fail_open: true\n\"\"\"\n\n_FALLBACK_COMMENT = \"\"\"\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\n_COMMENTED_SECTIONS = \"\"\"\n# ── Security ──────────────────────────────────────────────────────────\n# API keys, tokens, and passwords are redacted from tool output by default.\n# Set to false to see full values (useful for debugging auth issues).\n#\n# security:\n# redact_secrets: false\n\n# ── Fallback Model ────────────────────────────────────────────────────\n# Automatic provider failover when primary is unavailable.\n# Uncomment and configure to enable. Triggers on rate limits (429),\n# overload (529), service errors (503), or connection failures.\n#\n# Supported providers:\n# openrouter (OPENROUTER_API_KEY) — routes to any model\n# openai-codex (OAuth — spark auth) — OpenAI Codex\n\n# zai (ZAI_API_KEY) — Z.AI / GLM\n# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot\n# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)\n# minimax (MINIMAX_API_KEY) — MiniMax\n# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)\n#\n# For custom OpenAI-compatible endpoints, add base_url and api_key_env.\n#\n# fallback_model:\n# provider: openrouter\n# model: anthropic/claude-sonnet-4\n#\n# ── Multi-Model Routing ────────────────────────────────────────────────\n# Optional SMART/FAST model routing for simple turns.\n# Keep `model.default` as the SMART model for complex/coding work, and route\n# short/simple requests to the FAST model below. The easiest way to configure\n# this is `spark model` → Multi-model.\n#\n# smart_model_routing:\n# enabled: true\n# max_simple_chars: 160\n# max_simple_words: 28\n# cheap_model:\n# provider: openai-codex\n# model: gpt-5.4-mini\n# base_url: \"\"\n# api_mode: \"\"\n\"\"\"\n\n\ndef save_config(config: Dict[str, Any]):\n \"\"\"Save configuration to ~/.spark/config.yaml.\"\"\"\n if is_managed():\n managed_error(\"save configuration\")\n return\n from core.utils import atomic_yaml_write\n\n ensure_spark_home()\n config_path = get_config_path()\n normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n\n # Build optional commented-out sections for features that are off by\n # default or only relevant when explicitly configured.\n parts = []\n sec = normalized.get(\"security\", {})\n if not sec or sec.get(\"redact_secrets\") is None:\n parts.append(_SECURITY_COMMENT)\n fb = normalized.get(\"fallback_model\", {})\n if not fb or not (fb.get(\"provider\") and fb.get(\"model\")):\n parts.append(_FALLBACK_COMMENT)\n\n atomic_yaml_write(\n config_path,\n normalized,\n extra_content=\"\".join(parts) if parts else None,\n )\n _secure_file(config_path)\n\n\ndef load_env() -> Dict[str, str]:\n \"\"\"Load environment variables from ~/.spark/.env.\n\n Sanitizes lines before parsing so that corrupted files (e.g.\n concatenated KEY=VALUE pairs on a single line) are handled\n gracefully instead of producing mangled values such as duplicated\n bot tokens. See #8908.\n \"\"\"\n env_path = get_env_path()\n env_vars = {}\n\n if env_path.exists():\n # On Windows, open() defaults to the system locale (cp1252) which can\n # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.\n open_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n with open(env_path, **open_kw) as f:\n raw_lines = f.readlines()\n # Sanitize before parsing: split concatenated lines & drop stale\n # placeholders so corrupted .env files don't produce invalid tokens.\n lines = _sanitize_env_lines(raw_lines)\n for line in lines:\n line = line.strip()\n if line and not line.startswith(\"#\") and \"=\" in line:\n key, _, value = line.partition(\"=\")\n env_vars[key.strip()] = value.strip().strip(\"\\\"'\")\n\n return env_vars\n\n\ndef _sanitize_env_lines(lines: list) -> list:\n \"\"\"Fix corrupted .env lines before reading or writing.\n\n Handles two known corruption patterns:\n 1. Concatenated KEY=VALUE pairs on a single line (missing newline between\n entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).\n 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.\n\n Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only\n split on real Spark env var names, avoiding false positives from values\n that happen to contain uppercase text with ``=``.\n \"\"\"\n # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.\n # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n\n sanitized: list[str] = []\n for line in lines:\n raw = line.rstrip(\"\\r\\n\")\n stripped = raw.strip()\n\n # Preserve blank lines and comments\n if not stripped or stripped.startswith(\"#\"):\n sanitized.append(raw + \"\\n\")\n continue\n\n # Detect concatenated KEY=VALUE pairs on one line.\n # Search for known KEY= patterns at any position in the line.\n split_positions = []\n for key_name in known_keys:\n needle = key_name + \"=\"\n idx = stripped.find(needle)\n while idx >= 0:\n split_positions.append(idx)\n idx = stripped.find(needle, idx + len(needle))\n\n if len(split_positions) > 1:\n split_positions.sort()\n # Deduplicate (shouldn't happen, but be safe)\n split_positions = sorted(set(split_positions))\n for i, pos in enumerate(split_positions):\n end = (\n split_positions[i + 1]\n if i + 1 < len(split_positions)\n else len(stripped)\n )\n part = stripped[pos:end].strip()\n if part:\n sanitized.append(part + \"\\n\")\n else:\n sanitized.append(stripped + \"\\n\")\n\n return sanitized\n\n\ndef sanitize_env_file() -> int:\n \"\"\"Read, sanitize, and rewrite ~/.spark/.env in place.\n\n Returns the number of lines that were fixed (concatenation splits +\n placeholder removals). Returns 0 when no changes are needed.\n \"\"\"\n env_path = get_env_path()\n if not env_path.exists():\n return 0\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n original_lines = f.readlines()\n\n sanitized = _sanitize_env_lines(original_lines)\n\n if sanitized == original_lines:\n return 0\n\n # Count fixes: difference in line count (from splits) + removed lines\n fixes = abs(len(sanitized) - len(original_lines))\n if fixes == 0:\n # Lines changed content (e.g. *** removal) even if count is same\n fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)\n fixes += abs(len(sanitized) - len(original_lines))\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(sanitized)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n return fixes\n\n\ndef save_env_value(key: str, value: str):\n \"\"\"Save or update a value in ~/.spark/.env.\"\"\"\n if is_managed():\n managed_error(f\"set {key}\")\n return\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n value = value.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n ensure_spark_home()\n env_path = get_env_path()\n\n # On Windows, open() defaults to the system locale (cp1252) which can\n # cause OSError errno 22 on UTF-8 .env files.\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n # Sanitize on every read: split concatenated keys, drop stale placeholders\n lines = _sanitize_env_lines(lines)\n\n # Find and update or append\n found = False\n for i, line in enumerate(lines):\n if line.strip().startswith(f\"{key}=\"):\n lines[i] = f\"{key}={value}\\n\"\n found = True\n break\n\n if not found:\n # Ensure there's a newline at the end of the file before appending\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n lines.append(f\"{key}={value}\\n\")\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[key] = value\n\n # Restrict .env permissions to owner-only (contains API keys)\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\n# Defaults for gateway HTTP API (OpenAI-compatible); must match gateway.platforms.api_server\nHTTP_API_DEFAULT_HOST = \"127.0.0.1\"\nHTTP_API_DEFAULT_PORT = \"8642\"\n\n\ndef _is_api_server_env_line(line: str) -> bool:\n \"\"\"True if line assigns API_SERVER_ENABLED|KEY|HOST|PORT (commented or not).\"\"\"\n s = line.strip()\n if s.startswith(\"#\"):\n s = s[1:].strip()\n return s.startswith(\n (\n \"API_SERVER_ENABLED=\",\n \"API_SERVER_KEY=\",\n \"API_SERVER_HOST=\",\n \"API_SERVER_PORT=\",\n )\n )\n\n\ndef save_http_api_env_block(api_key: str) -> None:\n \"\"\"Write API server settings to ~/.spark/.env, replacing any prior API_SERVER_* lines.\n\n Sets API_SERVER_ENABLED=true, API_SERVER_KEY, API_SERVER_HOST (127.0.0.1),\n and API_SERVER_PORT (8642). Removes previous assignments whether commented or not.\n \"\"\"\n if is_managed():\n managed_error(\"configure HTTP API\")\n return\n api_key = api_key.replace(\"\\n\", \"\").replace(\"\\r\", \"\")\n if not api_key:\n raise ValueError(\"API key must be non-empty\")\n from spark_cli.auth import has_usable_secret\n\n if not has_usable_secret(api_key, min_length=8):\n raise ValueError(\n \"API key must be at least 8 characters and not a common placeholder\"\n )\n\n ensure_spark_home()\n env_path = get_env_path()\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n lines: list[str] = []\n if env_path.exists():\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n kept = [ln for ln in lines if not _is_api_server_env_line(ln.rstrip(\"\\r\\n\"))]\n block = [\n \"API_SERVER_ENABLED=true\\n\",\n f\"API_SERVER_KEY={api_key}\\n\",\n f\"API_SERVER_HOST={HTTP_API_DEFAULT_HOST}\\n\",\n f\"API_SERVER_PORT={HTTP_API_DEFAULT_PORT}\\n\",\n ]\n if kept and not kept[-1].endswith(\"\\n\"):\n kept[-1] += \"\\n\"\n kept.extend(block)\n\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(kept)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ[\"API_SERVER_ENABLED\"] = \"true\"\n os.environ[\"API_SERVER_KEY\"] = api_key\n os.environ[\"API_SERVER_HOST\"] = HTTP_API_DEFAULT_HOST\n os.environ[\"API_SERVER_PORT\"] = HTTP_API_DEFAULT_PORT\n\n if not _IS_WINDOWS:\n try:\n os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n\n\ndef remove_env_value(key: str) -> bool:\n \"\"\"Remove a key from ~/.spark/.env and os.environ.\n\n Returns True if the key was found and removed, False otherwise.\n \"\"\"\n if is_managed():\n managed_error(f\"remove {key}\")\n return False\n if not _ENV_VAR_NAME_RE.match(key):\n raise ValueError(f\"Invalid environment variable name: {key!r}\")\n env_path = get_env_path()\n if not env_path.exists():\n os.environ.pop(key, None)\n return False\n\n read_kw = {\"encoding\": \"utf-8\", \"errors\": \"replace\"} if _IS_WINDOWS else {}\n write_kw = {\"encoding\": \"utf-8\"} if _IS_WINDOWS else {}\n\n with open(env_path, **read_kw) as f:\n lines = f.readlines()\n lines = _sanitize_env_lines(lines)\n\n new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n found = len(new_lines) < len(lines)\n\n if found:\n fd, tmp_path = tempfile.mkstemp(\n dir=str(env_path.parent), suffix=\".tmp\", prefix=\".env_\"\n )\n try:\n with os.fdopen(fd, \"w\", **write_kw) as f:\n f.writelines(new_lines)\n f.flush()\n os.fsync(f.fileno())\n os.replace(tmp_path, env_path)\n except BaseException:\n try:\n os.unlink(tmp_path)\n except OSError:\n pass\n raise\n _secure_file(env_path)\n\n os.environ.pop(key, None)\n return found\n\n\ndef save_anthropic_oauth_token(value: str, save_fn=None):\n \"\"\"Persist an Anthropic OAuth/setup token and clear the API-key slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", value)\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef use_anthropic_claude_code_credentials(save_fn=None):\n \"\"\"Use Claude Code's own credential files instead of persisting env tokens.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_TOKEN\", \"\")\n writer(\"ANTHROPIC_API_KEY\", \"\")\n\n\ndef save_anthropic_api_key(value: str, save_fn=None):\n \"\"\"Persist an Anthropic API key and clear the OAuth/setup-token slot.\"\"\"\n writer = save_fn or save_env_value\n writer(\"ANTHROPIC_API_KEY\", value)\n writer(\"ANTHROPIC_TOKEN\", \"\")\n\n\ndef save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n save_env_value(key, value)\n return {\n \"success\": True,\n \"stored_as\": key,\n \"validated\": False,\n }\n\n\ndef reload_env() -> int:\n \"\"\"Re-read ~/.spark/.env into os.environ. Returns count of vars updated.\n\n Adds/updates vars that changed and removes vars that were deleted from\n the .env file (but only vars known to Spark — OPTIONAL_ENV_VARS and\n _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).\n \"\"\"\n env_vars = load_env()\n known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS\n count = 0\n for key, value in env_vars.items():\n if os.environ.get(key) != value:\n os.environ[key] = value\n count += 1\n # Remove known Spark vars that are no longer in .env\n for key in known_keys:\n if key not in env_vars and key in os.environ:\n del os.environ[key]\n count += 1\n return count\n\n\ndef get_env_value(key: str) -> Optional[str]:\n \"\"\"Get a value from ~/.spark/.env or environment.\"\"\"\n # Check environment first\n if key in os.environ:\n return os.environ[key]\n\n # Then check .env file\n env_vars = load_env()\n return env_vars.get(key)\n\n\n# =============================================================================\n# Config display\n# =============================================================================\n\n\ndef redact_key(key: str) -> str:\n \"\"\"Redact an API key for display.\"\"\"\n if not key:\n return color(\"(not set)\", Colors.DIM)\n if len(key) < 12:\n return \"***\"\n return key[:4] + \"...\" + key[-4:]\n\n\ndef show_config():\n \"\"\"Display current configuration.\"\"\"\n config = load_config()\n\n print()\n print(\n color(\n \"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN\n )\n )\n print(\n color(\"│ S Spark Configuration │\", Colors.CYAN)\n )\n print(\n color(\n \"└─────────────────────────────────────────────────────────┘\", Colors.CYAN\n )\n )\n\n # Paths\n print()\n print(color(\"◆ Paths\", Colors.CYAN, Colors.BOLD))\n print(f\" Config: {get_config_path()}\")\n print(f\" Secrets: {get_env_path()}\")\n print(f\" Install: {get_project_root()}\")\n\n # API Keys\n print()\n print(color(\"◆ API Keys\", Colors.CYAN, Colors.BOLD))\n\n keys = [\n (\"OPENROUTER_API_KEY\", \"OpenRouter\"),\n (\"VOICE_TOOLS_OPENAI_KEY\", \"OpenAI (STT/TTS)\"),\n (\"EXA_API_KEY\", \"Exa\"),\n (\"PARALLEL_API_KEY\", \"Parallel\"),\n (\"FIRECRAWL_API_KEY\", \"Firecrawl\"),\n (\"TAVILY_API_KEY\", \"Tavily\"),\n (\"BROWSERBASE_API_KEY\", \"Browserbase\"),\n (\"BROWSER_USE_API_KEY\", \"Browser Use\"),\n (\"FAL_KEY\", \"FAL\"),\n ]\n\n for env_key, name in keys:\n value = get_env_value(env_key)\n print(f\" {name:<14} {redact_key(value)}\")\n from spark_cli.auth import get_anthropic_key\n\n anthropic_value = get_anthropic_key()\n print(f\" {'Anthropic':<14} {redact_key(anthropic_value)}\")\n\n # Model settings\n print()\n print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n print(f\" Model: {config.get('model', 'not set')}\")\n print(\n f\" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}\"\n )\n\n # Display\n print()\n print(color(\"◆ Display\", Colors.CYAN, Colors.BOLD))\n display = config.get(\"display\", {})\n print(f\" Personality: {display.get('personality', 'kawaii')}\")\n print(f\" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}\")\n print(\n f\" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}\"\n )\n\n # Terminal\n print()\n print(color(\"◆ Terminal\", Colors.CYAN, Colors.BOLD))\n terminal = config.get(\"terminal\", {})\n print(f\" Backend: {terminal.get('backend', 'local')}\")\n print(f\" Working dir: {terminal.get('cwd', '.')}\")\n print(f\" Timeout: {terminal.get('timeout', 60)}s\")\n\n if terminal.get(\"backend\") == \"docker\":\n print(\n f\" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"singularity\":\n print(\n f\" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n elif terminal.get(\"backend\") == \"modal\":\n print(\n f\" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n modal_token = get_env_value(\"MODAL_TOKEN_ID\")\n print(f\" Modal token: {'configured' if modal_token else '(not set)'}\")\n elif terminal.get(\"backend\") == \"daytona\":\n print(\n f\" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}\"\n )\n daytona_key = get_env_value(\"DAYTONA_API_KEY\")\n print(f\" API key: {'configured' if daytona_key else '(not set)'}\")\n elif terminal.get(\"backend\") == \"ssh\":\n ssh_host = get_env_value(\"TERMINAL_SSH_HOST\")\n ssh_user = get_env_value(\"TERMINAL_SSH_USER\")\n print(f\" SSH host: {ssh_host or '(not set)'}\")\n print(f\" SSH user: {ssh_user or '(not set)'}\")\n\n # Timezone\n print()\n print(color(\"◆ Timezone\", Colors.CYAN, Colors.BOLD))\n tz = config.get(\"timezone\", \"\")\n if tz:\n print(f\" Timezone: {tz}\")\n else:\n print(f\" Timezone: {color('(server-local)', Colors.DIM)}\")\n\n # Compression\n print()\n print(color(\"◆ Context Compression\", Colors.CYAN, Colors.BOLD))\n compression = config.get(\"compression\", {})\n enabled = compression.get(\"enabled\", True)\n print(f\" Enabled: {'yes' if enabled else 'no'}\")\n if enabled:\n print(f\" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%\")\n print(\n f\" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved\"\n )\n print(f\" Protect last: {compression.get('protect_last_n', 20)} messages\")\n _aux_comp = config.get(\"auxiliary\", {}).get(\"compression\", {})\n _sm = _aux_comp.get(\"model\", \"\") or \"(auto)\"\n print(f\" Model: {_sm}\")\n comp_provider = _aux_comp.get(\"provider\", \"auto\")\n if comp_provider and comp_provider != \"auto\":\n print(f\" Provider: {comp_provider}\")\n\n # Auxiliary models\n auxiliary = config.get(\"auxiliary\", {})\n aux_tasks = {\n \"Vision\": auxiliary.get(\"vision\", {}),\n \"Web extract\": auxiliary.get(\"web_extract\", {}),\n }\n has_overrides = any(\n t.get(\"provider\", \"auto\") != \"auto\" or t.get(\"model\", \"\")\n for t in aux_tasks.values()\n )\n if has_overrides:\n print()\n print(color(\"◆ Auxiliary Models (overrides)\", Colors.CYAN, Colors.BOLD))\n for label, task_cfg in aux_tasks.items():\n prov = task_cfg.get(\"provider\", \"auto\")\n mdl = task_cfg.get(\"model\", \"\")\n if prov != \"auto\" or mdl:\n parts = [f\"provider={prov}\"]\n if mdl:\n parts.append(f\"model={mdl}\")\n print(f\" {label:12s} {', '.join(parts)}\")\n\n # Messaging\n print()\n print(color(\"◆ Messaging Platforms\", Colors.CYAN, Colors.BOLD))\n\n telegram_token = get_env_value(\"TELEGRAM_BOT_TOKEN\")\n discord_token = get_env_value(\"DISCORD_BOT_TOKEN\")\n\n print(\n f\" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}\"\n )\n print(\n f\" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}\"\n )\n\n # Skill config\n try:\n from agent.skill_utils import (\n discover_all_skill_config_vars,\n resolve_skill_config_values,\n )\n\n skill_vars = discover_all_skill_config_vars()\n if skill_vars:\n resolved = resolve_skill_config_values(skill_vars)\n print()\n print(color(\"◆ Skill Settings\", Colors.CYAN, Colors.BOLD))\n for var in skill_vars:\n key = var[\"key\"]\n value = resolved.get(key, \"\")\n skill_name = var.get(\"skill\", \"\")\n display_val = str(value) if value else color(\"(not set)\", Colors.DIM)\n print(\n f\" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}\"\n )\n except Exception:\n pass\n\n print()\n print(color(\"─\" * 60, Colors.DIM))\n print(color(\" spark config edit # Edit config file\", Colors.DIM))\n print(color(\" spark config set <key> <value>\", Colors.DIM))\n print(color(\" spark setup # Run setup wizard\", Colors.DIM))\n print()\n\n\ndef edit_config():\n \"\"\"Open config file in user's editor.\"\"\"\n if is_managed():\n managed_error(\"edit configuration\")\n return\n config_path = get_config_path()\n\n # Ensure config exists\n if not config_path.exists():\n save_config(DEFAULT_CONFIG)\n print(f\"Created {config_path}\")\n\n # Find editor\n editor = os.getenv(\"EDITOR\") or os.getenv(\"VISUAL\")\n\n if not editor:\n # Try common editors\n for cmd in [\"nano\", \"vim\", \"vi\", \"code\", \"notepad\"]:\n import shutil\n\n if shutil.which(cmd):\n editor = cmd\n break\n\n if not editor:\n print(\"No editor found. Config file is at:\")\n print(f\" {config_path}\")\n return\n\n print(f\"Opening {config_path} in {editor}...\")\n subprocess.run([editor, str(config_path)])\n\n\ndef set_config_value(key: str, value: str):\n \"\"\"Set a configuration value.\"\"\"\n if is_managed():\n managed_error(\"set configuration values\")\n return\n # Check if it's an API key (goes to .env)\n api_keys = [\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"VOICE_TOOLS_OPENAI_KEY\",\n \"EXA_API_KEY\",\n \"PARALLEL_API_KEY\",\n \"FIRECRAWL_API_KEY\",\n \"FIRECRAWL_API_URL\",\n \"FIRECRAWL_GATEWAY_URL\",\n \"TOOL_GATEWAY_DOMAIN\",\n \"TOOL_GATEWAY_SCHEME\",\n \"TOOL_GATEWAY_USER_TOKEN\",\n \"TAVILY_API_KEY\",\n \"BROWSERBASE_API_KEY\",\n \"BROWSERBASE_PROJECT_ID\",\n \"BROWSER_USE_API_KEY\",\n \"FAL_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n \"DISCORD_BOT_TOKEN\",\n \"TERMINAL_SSH_HOST\",\n \"TERMINAL_SSH_USER\",\n \"TERMINAL_SSH_KEY\",\n \"SUDO_PASSWORD\",\n \"SLACK_BOT_TOKEN\",\n \"SLACK_APP_TOKEN\",\n \"GITHUB_TOKEN\",\n \"HONCHO_API_KEY\",\n \"WANDB_API_KEY\",\n \"TINKER_API_KEY\",\n ]\n\n if (\n key.upper() in api_keys\n or key.upper().endswith((\"_API_KEY\", \"_TOKEN\"))\n or key.upper().startswith(\"TERMINAL_SSH\")\n ):\n save_env_value(key.upper(), value)\n print(f\"✓ Set {key} in {get_env_path()}\")\n return\n\n # Otherwise it goes to config.yaml\n # Read the raw user config (not merged with defaults) to avoid\n # dumping all default values back to the file\n config_path = get_config_path()\n user_config = {}\n if config_path.exists():\n try:\n with open(config_path, encoding=\"utf-8\") as f:\n user_config = yaml.safe_load(f) or {}\n except Exception:\n user_config = {}\n\n # Handle nested keys (e.g., \"tts.provider\")\n parts = key.split(\".\")\n current = user_config\n\n for part in parts[:-1]:\n if part not in current or not isinstance(current.get(part), dict):\n current[part] = {}\n current = current[part]\n\n # Convert value to appropriate type\n if value.lower() in (\"true\", \"yes\", \"on\"):\n value = True\n elif value.lower() in (\"false\", \"no\", \"off\"):\n value = False\n elif value.isdigit():\n value = int(value)\n elif value.replace(\".\", \"\", 1).isdigit():\n value = float(value)\n\n current[parts[-1]] = value\n\n # Write only user config back (not the full merged defaults)\n ensure_spark_home()\n from core.utils import atomic_yaml_write\n\n atomic_yaml_write(config_path, user_config, sort_keys=False)\n\n # Keep .env in sync for keys that terminal_tool reads directly from env vars.\n # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.\n _config_to_env_sync = {\n \"terminal.backend\": \"TERMINAL_ENV\",\n \"terminal.modal_mode\": \"TERMINAL_MODAL_MODE\",\n \"terminal.docker_image\": \"TERMINAL_DOCKER_IMAGE\",\n \"terminal.singularity_image\": \"TERMINAL_SINGULARITY_IMAGE\",\n \"terminal.modal_image\": \"TERMINAL_MODAL_IMAGE\",\n \"terminal.daytona_image\": \"TERMINAL_DAYTONA_IMAGE\",\n \"terminal.docker_mount_cwd_to_workspace\": \"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE\",\n \"terminal.cwd\": \"TERMINAL_CWD\",\n \"terminal.timeout\": \"TERMINAL_TIMEOUT\",\n \"terminal.sandbox_dir\": \"TERMINAL_SANDBOX_DIR\",\n \"terminal.persistent_shell\": \"TERMINAL_PERSISTENT_SHELL\",\n \"terminal.container_cpu\": \"TERMINAL_CONTAINER_CPU\",\n \"terminal.container_memory\": \"TERMINAL_CONTAINER_MEMORY\",\n \"terminal.container_disk\": \"TERMINAL_CONTAINER_DISK\",\n \"terminal.container_persistent\": \"TERMINAL_CONTAINER_PERSISTENT\",\n }\n if key in _config_to_env_sync:\n save_env_value(_config_to_env_sync[key], str(value))\n\n print(f\"✓ Set {key} = {value} in {config_path}\")\n\n\n# =============================================================================\n# Command handler\n# =============================================================================\n\n\ndef config_command(args):\n \"\"\"Handle config subcommands.\"\"\"\n subcmd = getattr(args, \"config_command\", None)\n\n if subcmd is None or subcmd == \"show\":\n show_config()\n\n elif subcmd == \"edit\":\n edit_config()\n\n elif subcmd == \"set\":\n key = getattr(args, \"key\", None)\n value = getattr(args, \"value\", None)\n if not key or value is None:\n print(\"Usage: spark config set <key> <value>\")\n print()\n print(\"Examples:\")\n print(\" spark config set model anthropic/claude-sonnet-4\")\n print(\" spark config set terminal.backend docker\")\n print(\" spark config set OPENROUTER_API_KEY sk-or-...\")\n sys.exit(1)\n set_config_value(key, value)\n\n elif subcmd == \"path\":\n print(get_config_path())\n\n elif subcmd == \"env-path\":\n print(get_env_path())\n\n elif subcmd == \"migrate\":\n print()\n print(\n color(\"🔄 Checking configuration for updates...\", Colors.CYAN, Colors.BOLD)\n )\n print()\n\n # Check what's missing\n missing_env = get_missing_env_vars(required_only=False)\n missing_config = get_missing_config_fields()\n current_ver, latest_ver = check_config_version()\n\n if not missing_env and not missing_config and current_ver >= latest_ver:\n print(color(\"✓ Configuration is up to date!\", Colors.GREEN))\n print()\n return\n\n # Show what needs to be updated\n if current_ver < latest_ver:\n print(f\" Config version: {current_ver} → {latest_ver}\")\n\n if missing_config:\n print(\n f\"\\n {len(missing_config)} new config option(s) will be added with defaults\"\n )\n\n required_missing = [v for v in missing_env if v.get(\"is_required\")]\n optional_missing = [\n v for v in missing_env if not v.get(\"is_required\") and not v.get(\"advanced\")\n ]\n\n if required_missing:\n print(f\"\\n ⚠️ {len(required_missing)} required API key(s) missing:\")\n for var in required_missing:\n print(f\" • {var['name']}\")\n\n if optional_missing:\n print(f\"\\n ℹ️ {len(optional_missing)} optional API key(s) not configured:\")\n for var in optional_missing:\n tools = var.get(\"tools\", [])\n tools_str = f\" (enables: {', '.join(tools[:2])})\" if tools else \"\"\n print(f\" • {var['name']}{tools_str}\")\n\n print()\n\n # Run migration\n results = migrate_config(interactive=True, quiet=False)\n\n print()\n if results[\"env_added\"] or results[\"config_added\"]:\n print(color(\"✓ Configuration updated!\", Colors.GREEN))\n\n if results[\"warnings\"]:\n print()\n for warning in results[\"warnings\"]:\n print(color(f\" ⚠️ {warning}\", Colors.YELLOW))\n\n print()\n\n elif subcmd == \"check\":\n # Non-interactive check for what's missing\n print()\n print(color(\"📋 Configuration Status\", Colors.CYAN, Colors.BOLD))\n print()\n\n current_ver, latest_ver = check_config_version()\n if current_ver >= latest_ver:\n print(f\" Config version: {current_ver} ✓\")\n else:\n print(\n color(\n f\" Config version: {current_ver} → {latest_ver} (update available)\",\n Colors.YELLOW,\n )\n )\n\n print()\n print(color(\" Required:\", Colors.BOLD))\n for var_name in REQUIRED_ENV_VARS:\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n print(color(f\" ✗ {var_name} (missing)\", Colors.RED))\n\n print()\n print(color(\" Optional:\", Colors.BOLD))\n for var_name, info in OPTIONAL_ENV_VARS.items():\n if get_env_value(var_name):\n print(f\" ✓ {var_name}\")\n else:\n tools = info.get(\"tools\", [])\n tools_str = f\" → {', '.join(tools[:2])}\" if tools else \"\"\n print(color(f\" ○ {var_name}{tools_str}\", Colors.DIM))\n\n missing_config = get_missing_config_fields()\n if missing_config:\n print()\n print(\n color(\n f\" {len(missing_config)} new config option(s) available\",\n Colors.YELLOW,\n )\n )\n print(\" Run 'spark config migrate' to add them\")\n\n print()\n\n else:\n print(f\"Unknown config command: {subcmd}\")\n print()\n print(\"Available commands:\")\n print(\" spark config Show current configuration\")\n print(\" spark config edit Open config in editor\")\n print(\" spark config set <key> <value> Set a config value\")\n print(\" spark config check Check for missing/outdated config\")\n print(\" spark config migrate Update config with new options\")\n print(\" spark config path Show config file path\")\n print(\" spark config env-path Show .env file path\")\n sys.exit(1)\n"}}}}