railwayapp 4.56.1

Interact with Railway via CLI
use std::{collections::BTreeMap, fs::File, io::Read, path::PathBuf};

use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use colored::Colorize;
use serde::{Deserialize, Serialize};

use crate::{telemetry, util};

const STATE_VERSION: u32 = 1;
const ADVISORY_INTERVAL_HOURS: i64 = 24;
const DISABLE_ENV: &str = "RAILWAY_AGENT_ADVISORY";
const FORCE_ENV: &str = "RAILWAY_AGENT";

#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AgentState {
    #[serde(default)]
    setup: SetupState,
    #[serde(default)]
    advisories: BTreeMap<String, AdvisoryState>,
}

#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SetupState {
    version: Option<u32>,
    last_run_at: Option<DateTime<Utc>>,
}

#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AdvisoryState {
    last_shown_at: Option<DateTime<Utc>>,
}

fn state_path() -> Result<PathBuf> {
    let home = dirs::home_dir().context("could not determine home directory")?;
    Ok(home.join(".railway").join("agent-state.json"))
}

fn read_state() -> AgentState {
    let Ok(path) = state_path() else {
        return AgentState::default();
    };
    let Ok(mut file) = File::open(path) else {
        return AgentState::default();
    };

    let mut contents = vec![];
    if file.read_to_end(&mut contents).is_err() {
        return AgentState::default();
    }

    serde_json::from_slice(&contents).unwrap_or_default()
}

fn write_state(state: &AgentState) -> Result<()> {
    let path = state_path()?;
    let contents = serde_json::to_string_pretty(state)?;
    util::write_atomic(&path, &contents)
}

fn agent_setup_is_current(state: &AgentState) -> bool {
    state.setup.version.unwrap_or_default() >= STATE_VERSION
}

fn disabled_by_env() -> bool {
    matches!(
        std::env::var(DISABLE_ENV).as_deref(),
        Ok("0" | "false" | "off")
    )
}

fn is_agent_environment() -> bool {
    if matches!(
        std::env::var(FORCE_ENV).as_deref(),
        Ok("1" | "true" | "yes")
    ) {
        return true;
    }

    const AGENT_ENV_VARS: &[&str] = &[
        "AIDER",
        "AMP_CURRENT_THREAD_ID",
        "COPILOT_AGENT_SESSION_ID",
        "COPILOT_CLI",
        "CLAUDECODE",
        "CLAUDE_CODE",
        "CURSOR_AGENT",
        "FACTORY_DROID",
        "GEMINI_CLI",
        "OPENCODE",
        "OPENAI_AGENT",
        "PI_CODING_AGENT",
        "REPLIT_AGENT",
    ];

    const AGENT_ENV_PREFIXES: &[&str] = &[
        "AMP_",
        "CLAUDE_CODE_",
        "CODEX_",
        "COPILOT_",
        "GEMINI_",
        "OPENCODE_",
    ];

    if std::env::var("AGENT")
        .map(|value| value.eq_ignore_ascii_case("amp"))
        .unwrap_or(false)
    {
        return true;
    }

    AGENT_ENV_VARS
        .iter()
        .any(|name| std::env::var_os(name).is_some())
        || std::env::vars_os().any(|(key, _)| {
            key.to_str()
                .map(|key| {
                    AGENT_ENV_PREFIXES
                        .iter()
                        .any(|prefix| key.starts_with(prefix))
                })
                .unwrap_or(false)
        })
}

fn command_is_exempt(command: &str) -> bool {
    matches!(
        command,
        "autoupdate"
            | "check_updates"
            | "check-updates"
            | "completion"
            | "docs"
            | "help"
            | "login"
            | "logout"
            | "mcp"
            | "setup"
            | "skills"
            | "telemetry"
            | "telemetry_cmd"
            | "upgrade"
            | "whoami"
    )
}

fn should_skip_for_args(raw_args: &[String]) -> bool {
    raw_args.iter().any(|arg| {
        matches!(
            arg.as_str(),
            "--json" | "--help" | "-h" | "--version" | "-V"
        )
    })
}

fn advisory_is_recent(state: &AgentState, command: &str) -> bool {
    state
        .advisories
        .get(command)
        .and_then(|advisory| advisory.last_shown_at)
        .map(|last_shown_at| {
            Utc::now() - last_shown_at < chrono::Duration::hours(ADVISORY_INTERVAL_HOURS)
        })
        .unwrap_or(false)
}

pub async fn maybe_show(raw_args: &[String], command: Option<&str>) {
    if disabled_by_env() || should_skip_for_args(raw_args) || !is_agent_environment() {
        return;
    }

    let Some(command) = command else {
        return;
    };
    if command_is_exempt(command) {
        return;
    }

    let mut state = read_state();
    if agent_setup_is_current(&state) || advisory_is_recent(&state, command) {
        return;
    }

    eprintln!(
        "\n{}\n\n{}\n\n{}",
        "IMPORTANT: Railway agent tooling not detected.".yellow().bold(),
        "Ask the user if they would like this agent to run `railway setup agent -y` to configure the Railway agent tooling (agent skills and MCP configurations)."
            .yellow(),
        "Your agent will work much more effectively in Railway with these installed.".yellow()
            .yellow()
    );

    state.advisories.insert(
        command.to_string(),
        AdvisoryState {
            last_shown_at: Some(Utc::now()),
        },
    );

    let _ = write_state(&state);

    telemetry::send(telemetry::CliTrackEvent {
        command: "agent_advisory".to_string(),
        sub_command: Some(command.to_string()),
        success: true,
        error_message: None,
        duration_ms: 0,
        cli_version: env!("CARGO_PKG_VERSION"),
        os: std::env::consts::OS,
        arch: std::env::consts::ARCH,
        is_ci: crate::config::Configs::env_is_ci(),
    })
    .await;
}

pub fn record_setup_complete() -> Result<()> {
    let mut state = read_state();
    state.setup = SetupState {
        version: Some(STATE_VERSION),
        last_run_at: Some(Utc::now()),
    };
    write_state(&state)
}