use std::fs;
use std::io::IsTerminal;
use anyhow::{bail, Context, Result};
use console::Style;
use dialoguer::{Confirm, Input, Select};
use toml_edit::DocumentMut;
use tuitbot_core::config::Config;
use tuitbot_core::startup::data_dir;
use super::detect;
use crate::commands::auth;
pub async fn run_setup(out: crate::output::CliOutput) -> Result<()> {
if !std::io::stdin().is_terminal() {
if out.is_json() {
out.error(
"Interactive setup requires a terminal. \
For non-interactive MCP usage, set environment variables.",
)?;
std::process::exit(1);
}
if out.quiet {
bail!("Interactive setup requires a terminal.");
}
bail!(
"Interactive setup requires a terminal.\n\n\
For non-interactive MCP usage, set environment variables:\n \
claude mcp add -s user \\\n \
-e TUITBOT_X_API__CLIENT_ID=your_client_id \\\n \
tuitbot -- tuitbot mcp serve"
);
}
let bold = Style::new().bold();
let dim = Style::new().dim();
let green = Style::new().green();
eprintln!();
eprintln!(
"{}",
bold.apply_to("Tuitbot MCP Setup — connect X to your AI coding assistant.")
);
eprintln!();
print_x_api_guide();
let client_id: String = Input::new()
.with_prompt("X API Client ID")
.validate_with(|input: &String| -> Result<(), &str> {
if input.trim().is_empty() {
Err("Client ID cannot be empty")
} else {
Ok(())
}
})
.interact_text()?;
let client_id = client_id.trim().to_string();
let dir = data_dir();
let config_path = dir.join("config.toml");
fs::create_dir_all(&dir)?;
write_or_merge_config(&config_path, &client_id)?;
eprintln!(
"{}",
green.apply_to(format!(" Config written to {}", config_path.display()))
);
eprintln!();
let do_auth = Confirm::new()
.with_prompt("Connect your X account now?")
.default(true)
.interact()?;
if do_auth {
let config_str = config_path.display().to_string();
let config =
Config::load(Some(&config_str)).context("Failed to load config after writing")?;
if let Err(e) = auth::execute(&config, None).await {
eprintln!("\nAuth failed: {e:#}");
eprintln!("{}", dim.apply_to("You can retry later with: tuitbot auth"));
eprintln!();
}
} else {
eprintln!(
"{}",
dim.apply_to("Skipped. Run `tuitbot auth` when ready.")
);
eprintln!();
}
let profiles = ["write", "readonly", "admin"];
let profile_descriptions = [
"write — full growth co-pilot (default, recommended)",
"readonly — read-only tools, no mutations",
"admin — all tools including Ads, Compliance, Stream Rules",
];
let selection = Select::new()
.with_prompt("MCP profile")
.items(profile_descriptions)
.default(0)
.interact()?;
let profile = profiles[selection];
eprintln!();
if detect::detect_claude_code() {
let do_register = Confirm::new()
.with_prompt("Register with Claude Code automatically?")
.default(true)
.interact()?;
if do_register {
match detect::register_with_claude_code(profile) {
Ok(()) => {
eprintln!("{}", green.apply_to(" Registered with Claude Code."));
}
Err(e) => {
eprintln!(" Registration failed: {e:#}");
eprintln!();
print_manual_snippet(profile);
}
}
} else {
print_manual_snippet(profile);
}
} else {
eprintln!(
"{}",
dim.apply_to("Claude Code not found on PATH. Add this to your MCP config:")
);
eprintln!();
eprintln!("{}", detect::mcp_json_snippet(profile));
}
eprintln!();
eprintln!("{}", bold.apply_to("Setup complete."));
eprintln!();
Ok(())
}
fn print_x_api_guide() {
let dim = Style::new().dim();
eprintln!(
"{}",
dim.apply_to("To get your Client ID (takes ~2 minutes):")
);
eprintln!(
"{}",
dim.apply_to(" 1. Go to https://developer.x.com/en/portal/dashboard")
);
eprintln!(
"{}",
dim.apply_to(" 2. Create a Project and App (or select an existing one)")
);
eprintln!(
"{}",
dim.apply_to(" 3. Under \"User authentication settings\", enable OAuth 2.0:")
);
eprintln!(
"{}",
dim.apply_to(" Type: \"Web App\" — Callback URL: http://127.0.0.1:8080/callback")
);
eprintln!(
"{}",
dim.apply_to(" 4. Copy the Client ID from the \"Keys and tokens\" tab")
);
eprintln!();
}
fn write_or_merge_config(config_path: &std::path::Path, client_id: &str) -> Result<()> {
if config_path.exists() {
let contents = fs::read_to_string(config_path)
.with_context(|| format!("Failed to read {}", config_path.display()))?;
let mut doc = contents
.parse::<DocumentMut>()
.with_context(|| format!("Failed to parse {}", config_path.display()))?;
if !doc.contains_key("x_api") {
doc["x_api"] = toml_edit::Item::Table(toml_edit::Table::new());
}
doc["x_api"]["client_id"] = toml_edit::value(client_id);
fs::write(config_path, doc.to_string())
.with_context(|| format!("Failed to write {}", config_path.display()))?;
} else {
let content = format!(
"\
[x_api]
client_id = \"{client_id}\"
# MCP-only setup: approval mode ensures all posts are reviewed.
approval_mode = true
"
);
fs::write(config_path, content)
.with_context(|| format!("Failed to write {}", config_path.display()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_or_merge_config_creates_new_file() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("config.toml");
write_or_merge_config(&config_path, "test-client-id").unwrap();
let contents = fs::read_to_string(&config_path).unwrap();
assert!(contents.contains("client_id = \"test-client-id\""));
assert!(contents.contains("[x_api]"));
assert!(contents.contains("approval_mode = true"));
}
#[test]
fn write_or_merge_config_merges_existing() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("config.toml");
fs::write(&config_path, "[business]\nproduct_name = \"MyApp\"\n").unwrap();
write_or_merge_config(&config_path, "new-client-id").unwrap();
let contents = fs::read_to_string(&config_path).unwrap();
assert!(contents.contains("client_id = \"new-client-id\""));
assert!(contents.contains("product_name = \"MyApp\""));
}
#[test]
fn write_or_merge_config_updates_existing_client_id() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("config.toml");
fs::write(&config_path, "[x_api]\nclient_id = \"old-id\"\n").unwrap();
write_or_merge_config(&config_path, "new-id").unwrap();
let contents = fs::read_to_string(&config_path).unwrap();
assert!(contents.contains("client_id = \"new-id\""));
assert!(!contents.contains("old-id"));
}
#[test]
fn write_or_merge_config_creates_x_api_section_if_missing() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("config.toml");
fs::write(&config_path, "approval_mode = true\n").unwrap();
write_or_merge_config(&config_path, "my-client").unwrap();
let contents = fs::read_to_string(&config_path).unwrap();
assert!(contents.contains("[x_api]"));
assert!(contents.contains("client_id = \"my-client\""));
assert!(contents.contains("approval_mode = true"));
}
#[test]
fn print_x_api_guide_does_not_panic() {
print_x_api_guide();
}
#[test]
fn profiles_array_has_expected_entries() {
let profiles = ["write", "readonly", "admin"];
assert_eq!(profiles.len(), 3);
assert_eq!(profiles[0], "write");
assert_eq!(profiles[1], "readonly");
assert_eq!(profiles[2], "admin");
}
#[test]
fn profile_descriptions_match_profiles() {
let profiles = ["write", "readonly", "admin"];
let descriptions = [
"write — full growth co-pilot (default, recommended)",
"readonly — read-only tools, no mutations",
"admin — all tools including Ads, Compliance, Stream Rules",
];
assert_eq!(profiles.len(), descriptions.len());
for (profile, desc) in profiles.iter().zip(descriptions.iter()) {
assert!(desc.starts_with(profile));
}
}
}
fn print_manual_snippet(profile: &str) {
let dim = Style::new().dim();
eprintln!("{}", dim.apply_to("Add this to your MCP config manually:"));
eprintln!();
eprintln!("{}", detect::mcp_json_snippet(profile));
}