import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, resolve } from "node:path"; /** * Result of {@link runConfigInit}: stdout/stderr text and a process exit code, * mirroring the rest of the CLI dispatcher. Code 0 = wrote a file (or would in * --dry-run); 1 = file existed and `force` was false; 2 = bad input. */ export interface ConfigInitResult { stdout: string; stderr: string; code: number; path: string; } export interface ConfigInitOptions { /** Target file path. Defaults to `~/.tdmcp/config.env`. A leading `~`, `~/`, or `~\` is expanded to the user's home directory. */ out?: string; /** Overwrite an existing file. Without it, refuses (code 1) if the path exists. */ force?: boolean; /** When true, returns the body that would be written without touching the filesystem. */ dryRun?: boolean; /** Shared TD bridge token to bake into the starter file as an uncommented `TDMCP_BRIDGE_TOKEN=...` line. */ bridgeToken?: string; } /** Expand a leading `~`, `~/`, or `~\\` in a path to the user's home directory. */ function expandHome(p: string): string { if (p === "~") return resolve(homedir()); if (p.startsWith("~/") || p.startsWith("~\\")) return resolve(homedir(), p.slice(2)); return resolve(p); } /** * Render the starter `.env`-style file body: every TDMCP_* env var the server * reads, with a sane default (or a clearly marked "unset" placeholder for * secrets) and a one-line comment per variable. Sourced via `set -a; source * config.env; set +a` or pasted into a shell. * * Kept in lock-step with src/utils/config.ts — any new TDMCP_* env there should * also land here (covered by the unit test that asserts the rendered body * mentions every config key). */ export function renderStarterConfig(opts: { bridgeToken?: string } = {}): string { const tokenLine = opts.bridgeToken ? `TDMCP_BRIDGE_TOKEN=${JSON.stringify(opts.bridgeToken)}` : '# TDMCP_BRIDGE_TOKEN=""'; return [ "# tdmcp starter config", "# Generated by `tdmcp config init`. Source with:", "# set -a && source ~/.tdmcp/config.env && set +a", "# or paste the lines you want into your shell rc.", "", "# --- Config resolution -----------------------------------------------------", "# These two control HOW the rest of the variables are loaded — set them in your", "# shell, not in this file (sourcing this file is too late to influence its own", "# discovery).", "# Absolute path to an alternate tdmcp config file (JSON). When set, this file", "# is loaded instead of the default search path (./tdmcp.json, ./.tdmcprc,", "# $XDG_CONFIG_HOME/tdmcp/config.json).", '# TDMCP_CONFIG_FILE=""', "# Active profile name to select from a multi-profile config file. Profiles let", "# you keep per-venue/per-show overrides under a single `profiles: {…}` block.", '# TDMCP_PROFILE=""', "", "# --- TouchDesigner bridge --------------------------------------------------", "# Host + port the WebServer DAT inside TD listens on.", 'TDMCP_TD_HOST="127.0.0.1"', 'TDMCP_TD_PORT="9980"', "# Per-request timeout against the TD bridge, in milliseconds.", 'TDMCP_REQUEST_TIMEOUT_MS="10000"', "# Optional shared bearer token. Set the SAME value in TD's environment to enforce auth.", tokenLine, "", "# --- MCP server ------------------------------------------------------------", '# Transport: "stdio" (default, for local clients) or "http" (Streamable HTTP, loopback-only).', 'TDMCP_TRANSPORT="stdio"', "# HTTP transport port (only used when TDMCP_TRANSPORT=http).", 'TDMCP_HTTP_PORT="3939"', "# Log verbosity. One of: debug, info, warn, error, silent.", 'TDMCP_LOG_LEVEL="info"', "# Forward TD WebSocket events as MCP logging notifications. on|off.", 'TDMCP_EVENTS="on"', "", "# --- Tool exposure ---------------------------------------------------------", '# Tool surface: "full" (every tool) or "safe" (hides destructive + raw-code tools).', 'TDMCP_TOOL_PROFILE="full"', "# Raw Python escape-hatch tools (execute_python_script, exec_node_method). on|off.", 'TDMCP_RAW_PYTHON="on"', "", "# --- Local LLM copilot (`tdmcp chat`) --------------------------------------", "# Base URL of an OpenAI-compatible chat endpoint. Default points at local Ollama.", 'TDMCP_LLM_BASE_URL="http://127.0.0.1:11434/v1"', "# Model id (must be pulled in the backend, e.g. `ollama pull qwen2.5:3b`).", 'TDMCP_LLM_MODEL="qwen2.5:3b"', "# Optional bearer token for paid/cloud LLM APIs (ignored by local Ollama).", '# TDMCP_LLM_API_KEY=""', '# Default copilot tool tier: "standard", "safe" (read-only), or "creative".', 'TDMCP_LLM_TIER="standard"', "# Max model/tool loop iterations for one local copilot turn (clamped to 1..32).", 'TDMCP_LLM_MAX_STEPS="8"', "# Sampling temperature for the local copilot (clamped to 0..2).", 'TDMCP_LLM_TEMPERATURE="0.4"', "# Loopback port the `tdmcp chat` web UI binds to.", 'TDMCP_CHAT_PORT="4141"', "", "# --- Telegram Bot API entry point (`tdmcp telegram`) ----------------------", "# Bot token from BotFather. Keep unset unless you run the Telegram adapter.", '# TDMCP_TELEGRAM_BOT_TOKEN=""', "# Comma-separated chat ids allowed to reach the local copilot.", '# TDMCP_TELEGRAM_ALLOWED_CHATS=""', "# Optional comma-separated user ids allowed to reach the local copilot.", '# TDMCP_TELEGRAM_ALLOWED_USERS=""', '# Default Telegram copilot tier: "safe" (default), "standard", or "creative".', 'TDMCP_TELEGRAM_DEFAULT_TIER="safe"', "# Telegram getUpdates long-poll timeout in seconds.", 'TDMCP_TELEGRAM_POLL_TIMEOUT_SEC="30"', "# Expiry for a pending non-safe Telegram prompt awaiting /approve.", 'TDMCP_TELEGRAM_CONFIRM_TIMEOUT_MS="60000"', "", "# --- Obsidian vault integration --------------------------------------------", "# Absolute path to an Obsidian vault folder; leave unset to disable vault tools.", '# TDMCP_VAULT_PATH=""', "", ].join("\n"); } /** * Write a starter config file (or print it under `--dry-run`). Never throws — * filesystem errors are returned as a non-zero exit code. The default target * is `~/.tdmcp/config.env`; pass `out` to override. Existing files are * refused unless `force` is true (so re-running the command is safe). */ export function runConfigInit(opts: ConfigInitOptions = {}): ConfigInitResult { const target = expandHome(opts.out ?? "~/.tdmcp/config.env"); const body = renderStarterConfig({ bridgeToken: opts.bridgeToken }); if (opts.dryRun) { return { stdout: body, stderr: `# Dry run — would write ${target}\n`, code: 0, path: target, }; } if (existsSync(target) && !opts.force) { return { stdout: "", stderr: `Refusing to overwrite existing file ${target}. Re-run with --force to replace it.\n`, code: 1, path: target, }; } try { mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, body, { mode: 0o600 }); return { stdout: `${target}\n`, stderr: `Wrote starter tdmcp config to ${target}.\nSource it with: set -a && source ${target} && set +a\n`, code: 0, path: target, }; } catch (err) { return { stdout: "", stderr: `Failed to write ${target}: ${(err as Error).message}\n`, code: 2, path: target, }; } }