use crate::browser_cookies;
use crate::config::{self, AppConfig};
use crate::context::AppContext;
use crate::errors::XmasterError;
use crate::output::{self, OutputFormat, Tableable};
use crate::providers::xapi::XApi;
use crate::providers::oauth2;
use serde::Serialize;
use std::sync::Arc;
#[derive(Serialize)]
struct ConfigDisplay {
config_path: String,
api_key: String,
api_secret: String,
access_token: String,
access_token_secret: String,
xai_key: String,
timeout: u64,
premium: bool,
#[serde(skip_serializing_if = "Option::is_none")]
voice: Option<String>,
}
impl Tableable for ConfigDisplay {
fn to_table(&self) -> comfy_table::Table {
let mut table = comfy_table::Table::new();
table.set_header(vec!["Setting", "Value"]);
table.add_row(vec!["Config path", &self.config_path]);
table.add_row(vec!["API Key", &self.api_key]);
table.add_row(vec!["API Secret", &self.api_secret]);
table.add_row(vec!["Access Token", &self.access_token]);
table.add_row(vec!["Access Token Secret", &self.access_token_secret]);
table.add_row(vec!["xAI Key", &self.xai_key]);
table.add_row(vec!["Timeout (s)", &self.timeout.to_string()]);
table.add_row(vec!["Premium", if self.premium { "yes" } else { "no" }]);
if let Some(ref v) = self.voice {
table.add_row(vec!["Voice", v]);
}
table
}
}
#[derive(Serialize)]
struct ConfigSetResult {
key: String,
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
previous_value: Option<String>,
}
impl Tableable for ConfigSetResult {
fn to_table(&self) -> comfy_table::Table {
let mut table = comfy_table::Table::new();
table.set_header(vec!["Key", "Status"]);
table.add_row(vec![
self.key.as_str(),
if self.success { "Updated" } else { "Failed" },
]);
if let Some(ref prev) = self.previous_value {
table.add_row(vec!["Previous value", prev]);
}
table
}
}
#[derive(Serialize)]
struct ConfigCheckResult {
x_auth: SubsystemStatus,
xai_auth: SubsystemStatus,
oauth2_bookmarks: SubsystemStatus,
web_reply_fallback: SubsystemStatus,
database: SubsystemStatus,
scheduler: SubsystemStatus,
}
#[derive(Serialize)]
struct SubsystemStatus {
configured: bool,
healthy: bool,
detail: String,
}
type AuthStatus = SubsystemStatus;
impl Tableable for ConfigCheckResult {
fn to_table(&self) -> comfy_table::Table {
let mut table = comfy_table::Table::new();
table.set_header(vec!["Subsystem", "Configured", "Healthy", "Detail"]);
let rows: Vec<(&str, &SubsystemStatus)> = vec![
("X API (OAuth1)", &self.x_auth),
("xAI", &self.xai_auth),
("OAuth2 Bookmarks", &self.oauth2_bookmarks),
("Web Reply Fallback", &self.web_reply_fallback),
("Database", &self.database),
("Scheduler", &self.scheduler),
];
for (name, s) in rows {
table.add_row(vec![
name,
if s.configured { "Yes" } else { "No" },
if s.healthy { "Yes" } else { "No" },
&s.detail,
]);
}
table
}
}
fn mask(key: &str) -> String {
if key.is_empty() {
"(not set)".into()
} else {
AppConfig::masked_key(key)
}
}
pub async fn show(_ctx: Arc<AppContext>, format: OutputFormat) -> Result<(), XmasterError> {
let cfg = config::load_config()?;
let voice = if cfg.style.voice.is_empty() {
None
} else {
Some(cfg.style.voice)
};
let display = ConfigDisplay {
config_path: config::config_path().to_string_lossy().to_string(),
api_key: mask(&cfg.keys.api_key),
api_secret: mask(&cfg.keys.api_secret),
access_token: mask(&cfg.keys.access_token),
access_token_secret: mask(&cfg.keys.access_token_secret),
xai_key: mask(&cfg.keys.xai),
timeout: cfg.settings.timeout,
premium: cfg.account.premium,
voice,
};
output::render(format, &display, None);
Ok(())
}
pub async fn get(format: OutputFormat, key: &str) -> Result<(), XmasterError> {
let cfg = config::load_config()?;
let value = match key {
"style.voice" => cfg.style.voice.clone(),
"niche.topics" => cfg.niche.topics.clone(),
"account.premium" => cfg.account.premium.to_string(),
"settings.timeout" => cfg.settings.timeout.to_string(),
"keys.xai" => mask(&cfg.keys.xai),
"keys.api_key" => mask(&cfg.keys.api_key),
"keys.api_secret" => mask(&cfg.keys.api_secret),
"keys.access_token" => mask(&cfg.keys.access_token),
"keys.access_token_secret" => mask(&cfg.keys.access_token_secret),
"keys.oauth2_client_id" => mask(&cfg.keys.oauth2_client_id),
"keys.oauth2_client_secret" => mask(&cfg.keys.oauth2_client_secret),
"keys.oauth2_access_token" => mask(&cfg.keys.oauth2_access_token),
"keys.oauth2_refresh_token" => mask(&cfg.keys.oauth2_refresh_token),
"keys.web_ct0" => mask(&cfg.keys.web_ct0),
"keys.web_auth_token" => mask(&cfg.keys.web_auth_token),
"keys.graphql_create_tweet_id" => mask(&cfg.keys.graphql_create_tweet_id),
_ => return Err(XmasterError::Config(format!("Unknown config key: {key}"))),
};
let display = ConfigGetResult { key: key.to_string(), value };
output::render(format, &display, None);
Ok(())
}
#[derive(Serialize)]
struct ConfigGetResult {
key: String,
value: String,
}
impl Tableable for ConfigGetResult {
fn to_table(&self) -> comfy_table::Table {
let mut table = comfy_table::Table::new();
table.set_header(vec!["Key", "Value"]);
table.add_row(vec![&self.key, &self.value]);
table
}
}
fn set_silent(key: &str, value: &str) -> Result<Option<String>, XmasterError> {
let path = config::config_path();
let existing = if path.exists() {
std::fs::read_to_string(&path).unwrap_or_default()
} else {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
String::new()
};
let mut doc: toml::Table = existing
.parse()
.map_err(|e: toml::de::Error| XmasterError::Config(format!("Failed to parse config: {e}")))?;
let parts: Vec<&str> = key.split('.').collect();
let toml_value = if value == "true" {
toml::Value::Boolean(true)
} else if value == "false" {
toml::Value::Boolean(false)
} else if let Ok(n) = value.parse::<i64>() {
toml::Value::Integer(n)
} else {
toml::Value::String(value.to_string())
};
let previous = match parts.len() {
1 => {
let prev = doc.get(parts[0]).map(|v| v.to_string());
doc.insert(parts[0].to_string(), toml_value);
prev
}
2 => {
let section = doc
.entry(parts[0].to_string())
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
let prev = if let toml::Value::Table(ref t) = section {
t.get(parts[1]).map(|v| v.to_string())
} else {
None
};
if let toml::Value::Table(ref mut t) = section {
t.insert(parts[1].to_string(), toml_value);
}
prev
}
_ => {
return Err(XmasterError::Config(format!("Invalid key path: {key}")));
}
};
let previous = previous.filter(|s| !s.is_empty());
let toml_str = toml::to_string_pretty(&doc)
.map_err(|e| XmasterError::Config(format!("Failed to serialize config: {e}")))?;
std::fs::write(&path, toml_str)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(previous)
}
pub async fn set(format: OutputFormat, key: &str, value: &str) -> Result<(), XmasterError> {
if !VALID_CONFIG_KEYS.contains(&key) {
let suggestions: Vec<&&str> = VALID_CONFIG_KEYS
.iter()
.filter(|k| {
k.contains(&key.split('.').last().unwrap_or(key))
|| key.contains(&k.split('.').last().unwrap_or(k))
})
.collect();
let hint = if suggestions.is_empty() {
String::new()
} else {
format!(
". Did you mean: {}?",
suggestions.iter().map(|s| format!("'{s}'")).collect::<Vec<_>>().join(", ")
)
};
return Err(XmasterError::Config(format!(
"Unknown config key '{key}'{hint} Run 'xmaster config show' to see valid keys."
)));
}
let previous = set_silent(key, value)?;
let display = ConfigSetResult {
key: key.to_string(),
success: true,
previous_value: previous,
};
output::render(format, &display, None);
Ok(())
}
const VALID_CONFIG_KEYS: &[&str] = &[
"keys.api_key", "keys.api_secret", "keys.access_token", "keys.access_token_secret",
"keys.xai",
"keys.oauth2_client_id", "keys.oauth2_client_secret",
"keys.oauth2_access_token", "keys.oauth2_refresh_token",
"keys.web_ct0", "keys.web_auth_token", "keys.graphql_create_tweet_id",
"settings.timeout",
"account.premium",
"style.voice",
];
pub async fn check(ctx: Arc<AppContext>, format: OutputFormat) -> Result<(), XmasterError> {
let x_configured = ctx.config.has_x_auth();
let xai_configured = ctx.config.has_xai_auth();
let x_auth = if x_configured {
let api = XApi::new(ctx.clone());
match api.get_me().await {
Ok(user) => AuthStatus {
configured: true,
healthy: true,
detail: format!("Authenticated as @{}", user.username),
},
Err(e) => AuthStatus {
configured: true,
healthy: false,
detail: format!("Auth failed: {e}"),
},
}
} else {
AuthStatus {
configured: false,
healthy: false,
detail: "X API credentials not set".into(),
}
};
let xai_auth = AuthStatus {
configured: xai_configured,
healthy: xai_configured,
detail: if xai_configured {
"xAI API key configured".into()
} else {
"xAI API key not set".into()
},
};
let oauth2_configured = !ctx.config.keys.oauth2_client_id.is_empty()
&& !ctx.config.keys.oauth2_client_secret.is_empty();
let oauth2_has_token = !ctx.config.keys.oauth2_access_token.is_empty();
let oauth2_bookmarks = AuthStatus {
configured: oauth2_configured,
healthy: oauth2_has_token,
detail: if oauth2_has_token {
"OAuth2 tokens present".into()
} else if oauth2_configured {
"Client credentials set but no token — run: xmaster config auth".into()
} else {
"OAuth2 not configured — needed for bookmarks".into()
},
};
let web_configured = ctx.config.has_web_cookies();
let web_reply_fallback = AuthStatus {
configured: web_configured,
healthy: web_configured,
detail: if web_configured {
"Web cookies present — reply fallback active".into()
} else {
"Not configured — run: xmaster config web-login".into()
},
};
let db_status = match crate::intel::tracker::PostTracker::open() {
Ok(tracker) => {
let status = tracker.tracking_status();
match status {
Ok(ts) => AuthStatus {
configured: true,
healthy: true,
detail: format!("{} tracked posts", ts.total),
},
Err(e) => AuthStatus {
configured: true,
healthy: false,
detail: format!("DB query error: {e}"),
},
}
}
Err(e) => AuthStatus {
configured: false,
healthy: false,
detail: format!("DB open error: {e}"),
},
};
let scheduler_status = match crate::intel::scheduler::PostScheduler::open() {
Ok(sched) => {
let pending = sched.list(Some("pending")).map(|v| v.len()).unwrap_or(0);
AuthStatus {
configured: true,
healthy: true,
detail: format!("{} pending scheduled posts", pending),
}
}
Err(e) => AuthStatus {
configured: false,
healthy: false,
detail: format!("Scheduler error: {e}"),
},
};
let display = ConfigCheckResult {
x_auth,
xai_auth,
oauth2_bookmarks,
web_reply_fallback,
database: db_status,
scheduler: scheduler_status,
};
output::render(format, &display, None);
Ok(())
}
#[derive(Serialize)]
struct SetupGuide {
steps: Vec<SetupStep>,
note: String,
}
#[derive(Serialize)]
struct SetupStep {
step: u32,
title: String,
instructions: String,
url: Option<String>,
command: Option<String>,
}
impl Tableable for SetupGuide {
fn to_table(&self) -> comfy_table::Table {
let mut table = comfy_table::Table::new();
table.set_header(vec!["Step", "What to do"]);
for s in &self.steps {
let mut detail = s.instructions.clone();
if let Some(ref url) = s.url {
detail.push_str(&format!("\n URL: {url}"));
}
if let Some(ref cmd) = s.command {
detail.push_str(&format!("\n Run: {cmd}"));
}
table.add_row(vec![
format!("{}. {}", s.step, s.title),
detail,
]);
}
table.add_row(vec!["Note".into(), self.note.clone()]);
table
}
}
pub async fn guide(format: OutputFormat) -> Result<(), XmasterError> {
let guide = SetupGuide {
steps: vec![
SetupStep {
step: 1,
title: "Create X Developer Account".into(),
instructions: "Go to the X Developer Portal. Sign in with your X account. Accept the Developer Agreement (describe your use case as: 'Personal AI assistant for posting and managing my X account').".into(),
url: Some("https://developer.x.com/en/portal/petition/essential/basic-info".into()),
command: None,
},
SetupStep {
step: 2,
title: "Create a Project and App".into(),
instructions: "In the Developer Portal dashboard, create a new Project. Inside it, create an App. Name it whatever you like (e.g., 'xmaster').".into(),
url: Some("https://developer.x.com/en/portal/dashboard".into()),
command: None,
},
SetupStep {
step: 3,
title: "Set App Permissions to Read+Write+DM".into(),
instructions: "Go to your App -> Settings -> User authentication settings. Set App permissions to 'Read and write and Direct message'. Set Type of App to 'Native App'. Set Callback URL to http://localhost:3000/callback. Set Website URL to https://github.com/199-biotechnologies/xmaster. Save.".into(),
url: None,
command: None,
},
SetupStep {
step: 4,
title: "Generate Keys and Tokens".into(),
instructions: "Go to your App -> Keys and tokens tab. Copy: API Key (Consumer Key), API Secret (Consumer Secret). Then under 'Access Token and Secret', click Generate. IMPORTANT: Generate tokens AFTER setting permissions in Step 3, or they'll be read-only.".into(),
url: None,
command: None,
},
SetupStep {
step: 5,
title: "Configure xmaster with your keys".into(),
instructions: "Run these commands with your actual keys:".into(),
url: None,
command: Some("xmaster config set keys.api_key YOUR_API_KEY\nxmaster config set keys.api_secret YOUR_API_SECRET\nxmaster config set keys.access_token YOUR_ACCESS_TOKEN\nxmaster config set keys.access_token_secret YOUR_ACCESS_TOKEN_SECRET".into()),
},
SetupStep {
step: 6,
title: "Verify everything works".into(),
instructions: "This should show your X username:".into(),
url: None,
command: Some("xmaster config check".into()),
},
SetupStep {
step: 7,
title: "(Optional) Add xAI key for AI-powered search".into(),
instructions: "Get an API key from the xAI console. This enables 'xmaster search-ai' which uses Grok for smarter, cheaper search.".into(),
url: Some("https://console.x.ai/".into()),
command: Some("xmaster config set keys.xai YOUR_XAI_KEY".into()),
},
],
note: "If posting fails with 403 'oauth1-permissions', your Access Token was generated before enabling Read+Write. Go back to Keys and tokens, click Regenerate on Access Token, and update xmaster with the new values.".into(),
};
output::render(format, &guide, None);
Ok(())
}
#[derive(Serialize)]
struct AuthResult {
status: String,
message: String,
auth_url: Option<String>,
next_step: Option<String>,
}
impl Tableable for AuthResult {
fn to_table(&self) -> comfy_table::Table {
let mut table = comfy_table::Table::new();
table.set_header(vec!["Field", "Value"]);
table.add_row(vec!["Status", &self.status]);
table.add_row(vec!["Message", &self.message]);
if let Some(ref url) = self.auth_url {
table.add_row(vec!["Auth URL", url]);
}
if let Some(ref next) = self.next_step {
table.add_row(vec!["Next Step", next]);
}
table
}
}
pub async fn auth(ctx: Arc<AppContext>, format: OutputFormat) -> Result<(), XmasterError> {
let client_id = &ctx.config.keys.oauth2_client_id;
let client_secret = &ctx.config.keys.oauth2_client_secret;
if client_id.is_empty() || client_secret.is_empty() {
let result = AuthResult {
status: "missing_credentials".into(),
message: "OAuth 2.0 Client ID and Secret not configured.".into(),
auth_url: None,
next_step: Some(
"Get them from developer.x.com → your app → Keys and tokens → OAuth 2.0 Client ID and Client Secret. \
Then run: xmaster config set keys.oauth2_client_id YOUR_ID && \
xmaster config set keys.oauth2_client_secret YOUR_SECRET".into()
),
};
output::render(format, &result, None);
return Ok(());
}
oauth2::authorize(client_id, client_secret).await?;
let result = AuthResult {
status: "success".into(),
message: "OAuth 2.0 authorization complete! Tokens saved to config.".into(),
auth_url: None,
next_step: Some("You can now use: xmaster bookmarks list".into()),
};
output::render(format, &result, None);
Ok(())
}
#[derive(Serialize)]
struct WebLoginResult {
status: String,
browser: String,
message: String,
ct0_preview: String,
auth_token_preview: String,
}
impl Tableable for WebLoginResult {
fn to_table(&self) -> comfy_table::Table {
let mut table = comfy_table::Table::new();
table.set_header(vec!["Field", "Value"]);
table.add_row(vec!["Status", &self.status]);
table.add_row(vec!["Browser", &self.browser]);
table.add_row(vec!["ct0", &self.ct0_preview]);
table.add_row(vec!["auth_token", &self.auth_token_preview]);
table.add_row(vec!["Message", &self.message]);
table
}
}
pub async fn web_login(format: OutputFormat) -> Result<(), XmasterError> {
if format == OutputFormat::Table {
eprintln!("Scanning browsers for X session cookies...");
}
let cookies = browser_cookies::extract()?;
set_silent("keys.web_ct0", &cookies.ct0)?;
set_silent("keys.web_auth_token", &cookies.auth_token)?;
let result = WebLoginResult {
status: "success".into(),
browser: "auto-detected".into(),
message: "Web cookies saved. Replies will now auto-fallback to web session if API blocks them.".into(),
ct0_preview: AppConfig::masked_key(&cookies.ct0),
auth_token_preview: AppConfig::masked_key(&cookies.auth_token),
};
output::render(format, &result, None);
Ok(())
}