use anyhow::Result;
use dialoguer::{Input, Password, Select};
use serde_json::json;
use tracing::info;
use super::config_json::{
get_nested_value, load_config_json, remove_nested_value, set_nested_value,
};
use rsclaw_agent as agent;
use rsclaw_cli::{ConfigureArgs, OnboardArgs, SetupArgs};
pub(crate) fn generate_auth_token() -> String {
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
};
let mut buf = [0u8; 32];
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let pid = std::process::id();
let mut hasher = DefaultHasher::new();
now.as_nanos().hash(&mut hasher);
pid.hash(&mut hasher);
let h1 = hasher.finish();
hasher = DefaultHasher::new();
(h1 ^ 0xdeadbeef_cafebabe).hash(&mut hasher);
std::thread::current().id().hash(&mut hasher);
let h2 = hasher.finish();
buf[..8].copy_from_slice(&h1.to_le_bytes());
buf[8..16].copy_from_slice(&h2.to_le_bytes());
for i in (16..32).step_by(8) {
hasher = DefaultHasher::new();
buf[..i].hash(&mut hasher);
let h = hasher.finish();
buf[i..i + 8].copy_from_slice(&h.to_le_bytes());
}
buf.iter().map(|b| format!("{b:02x}")).collect()
}
enum StepResult<T> {
Next(T),
Back,
Cancel,
}
fn select_step(prompt: &str, items: &[&str], default: usize) -> StepResult<usize> {
match Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt)
.items(items)
.default(default)
.interact_opt()
{
Ok(Some(idx)) => StepResult::Next(idx),
Ok(None) => StepResult::Back,
Err(e) => {
eprintln!(" ! interactive prompt failed: {e}");
eprintln!(" (configure needs a real terminal; not a pipe/redirect/non-TTY stdin)");
StepResult::Cancel
}
}
}
fn input_step<T>(prompt: &str, default: T) -> StepResult<T>
where
T: Clone + ToString + std::str::FromStr,
<T as std::str::FromStr>::Err: std::fmt::Debug + std::fmt::Display,
{
let current = default.to_string();
if !current.is_empty() {
let display_val = if current.chars().count() > 50 {
let end = current
.char_indices()
.nth(47)
.map(|(i, _)| i)
.unwrap_or(current.len());
format!("{}...", ¤t[..end])
} else {
current.clone()
};
let lang = rsclaw_i18n::default_lang();
let keep_label = rsclaw_i18n::t_fmt("cli_keep", lang, &[("value", &display_val)]);
let edit_label = rsclaw_i18n::t("cli_edit", lang);
let back_label = rsclaw_i18n::t("cli_back", lang);
let items = &[
keep_label.as_str(),
edit_label.as_str(),
back_label.as_str(),
];
match Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt)
.items(items)
.default(0)
.interact_opt()
{
Ok(Some(0)) => return StepResult::Next(default), Ok(Some(2)) | Ok(None) => return StepResult::Back,
Ok(Some(1)) => {} _ => return StepResult::Cancel,
}
}
match Input::<T>::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt)
.default(default)
.interact_text()
{
Ok(val) => StepResult::Next(val),
Err(_) => StepResult::Back,
}
}
fn password_step(prompt: &str) -> StepResult<String> {
match Password::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt)
.allow_empty_password(true)
.interact()
{
Ok(val) => StepResult::Next(val),
Err(_) => StepResult::Back,
}
}
fn confirm_step(prompt: &str, default: bool) -> StepResult<bool> {
match dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(prompt)
.default(default)
.interact_opt()
{
Ok(Some(val)) => StepResult::Next(val),
Ok(None) => StepResult::Back,
Err(_) => StepResult::Cancel,
}
}
fn default_config(lang: &str) -> String {
let lang_name = lang_code_to_name(lang);
let auth_token = generate_auth_token();
format!(
r#"// rsclaw configuration (JSON5)
// Docs: https://github.com/rsclaw-ai/rsclaw
{{
gateway: {{
port: 18888,
bind: "loopback",
language: "{lang_name}",
auth: {{ token: "{auth_token}" }},
}},
models: {{
providers: {{
// RsClaw stateful incremental session protocol (kvCacheMode=2).
// Recommended default. The bare name `rsclaw` auto-resolves to the
// managed fleet at api.rsclaw.ai using the rsclaw API format — set
// `baseUrl` here only if you want to point at a self-hosted
// rsclaw-llm worker (e.g. http://localhost:9999).
rsclaw: {{ apiKey: "${{RSCLAW_API_KEY}}" }},
// anthropic: {{ apiKey: "${{ANTHROPIC_API_KEY}}" }},
// openai: {{ apiKey: "${{OPENAI_API_KEY}}" }},
// ollama: {{ baseUrl: "http://localhost:11434" }},
// minimax: {{ apiKey: "${{MINIMAX_API_KEY}}" }},
// doubao: {{ baseUrl: "https://ark.cn-beijing.volces.com/api/v3" }},
}},
}},
agents: {{
defaults: {{
model: {{ primary: "rsclaw/rsclaw-agent-v1" }},
contextTokens: 128000, // max context window tokens
stripThinkTags: false, // strip <think> tags (auto when thinking disabled)
frequencyPenalty: 0.3, // reduce repetition (0.0-2.0)
thinking: {{ budget: 0 }}, // set > 0 to enable model reasoning
compaction: {{
mode: "layered", // "layered" | "default" | "safeguard"
reserveTokensFloor: 8000, // trigger compaction above this threshold
keepRecentPairs: 5, // keep N recent user-assistant pairs intact
extractFacts: true, // extract key facts to long-term memory
maxTranscriptTokens: 16000, // token budget for compact LLM
}},
}},
list: [
{{
id: "main",
}},
],
}},
tools: {{
webSearch: {{
// provider: "bing-free", // bing-free | baidu-free | sogou-free | duckduckgo-free | serper | google | bing | brave
// serperApiKey: "${{SERPER_API_KEY}}",
}},
webFetch: {{
maxLength: 100000, // max content chars
// summaryModel: "doubao-lite", // secondary model for content summarization
}},
webBrowser: {{
// headed: false, // true = visible Chrome window
// chromePath: "/path/to/chrome",
}},
}},
memory: {{
enabled: true,
recallTopK: 10, // results per backend before fusion
recallFinalK: 5, // final results after RRF fusion
}},
memorySearch: {{
provider: "local", // "local" | "openai" | "ollama"
local: {{
modelRepo: "BAAI/bge-small-zh-v1.5",
{model_download_url}
}},
}},
// Model files auto-downloaded to $BASE_DIR/models/ on first startup.
// channels: {{
// telegram: {{ botToken: "${{TELEGRAM_BOT_TOKEN}}" }},
// feishu: {{ appId: "xxx", appSecret: "xxx" }},
// discord: {{ token: "${{DISCORD_BOT_TOKEN}}" }},
// }},
}}
"#,
lang_name = lang_name,
auth_token = auth_token,
model_download_url =
r#"// modelDownloadUrl: "https://gitfast.org/tools/models/bge-small-zh-v1.5.zip","#,
)
}
#[allow(dead_code)]
fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> anyhow::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let ft = entry.file_type()?;
let dest = dst.join(entry.file_name());
if ft.is_dir() {
copy_dir_recursive(&entry.path(), &dest)?;
} else if ft.is_file() {
std::fs::copy(entry.path(), &dest)?;
}
}
Ok(())
}
fn header(title: &str) {
println!();
println!(" {title}");
println!(" {}", "-".repeat(title.len()));
}
fn step(icon: &str, msg: &str) {
println!(" {icon} {msg}");
}
fn done(msg: &str) {
println!();
println!(" [ok] {msg}");
}
fn hint(msg: &str) {
println!(" {msg}");
}
fn select_language() -> Result<&'static str> {
let labels = [
"中文 (Chinese)",
"English",
"Francais (French)",
"Deutsch (German)",
"日本語 (Japanese)",
"한국어 (Korean)",
"Espanol (Spanish)",
"Русский (Russian)",
"ไทย (Thai)",
"Tieng Viet (Vietnamese)",
];
let codes: [&str; 10] = ["zh", "en", "fr", "de", "ja", "ko", "es", "ru", "th", "vi"];
println!();
println!(" Language / 语言");
println!(" ----------------");
let selection = Select::new()
.items(&labels)
.default(0) .interact_opt()?;
let idx = match selection {
Some(i) => i,
None => {
println!("\n Setup cancelled.");
std::process::exit(0);
}
};
let lang = codes[idx];
rsclaw_i18n::set_default_lang(lang);
Ok(lang)
}
fn detect_lan_ips() -> Vec<String> {
let mut ips = Vec::new();
if cfg!(windows) {
#[allow(unused_mut)]
let mut ipc = std::process::Command::new("ipconfig");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
ipc.creation_flags(0x08000000);
}
if let Ok(output) = ipc.output() {
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
let trimmed = line.trim();
if let Some(pos) = trimmed.find(": ") {
let ip = trimmed[pos + 2..].trim();
if ip.contains('.') && !ip.starts_with("127.") && !ip.starts_with("169.254.") {
ips.push(ip.to_owned());
}
}
}
}
} else {
if let Ok(output) = std::process::Command::new("ifconfig").output() {
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("inet ") {
let ip = rest.split_whitespace().next().unwrap_or("");
if !ip.starts_with("127.") && !ip.starts_with("169.254.") && !ip.is_empty() {
ips.push(ip.to_owned());
}
}
}
}
if ips.is_empty() {
if let Ok(output) = std::process::Command::new("ip")
.args(["addr", "show"])
.output()
{
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("inet ") {
let ip = rest.split('/').next().unwrap_or("");
if !ip.starts_with("127.") && !ip.starts_with("169.254.") && !ip.is_empty()
{
ips.push(ip.to_owned());
}
}
}
}
}
}
ips
}
fn build_bind_options() -> (Vec<String>, Vec<String>) {
let mut labels = vec![
"loopback (127.0.0.1 only)".to_string(),
"all (0.0.0.0, public)".to_string(),
];
let mut values = vec!["loopback".to_string(), "all".to_string()];
let lan_ips = detect_lan_ips();
for ip in &lan_ips {
labels.push(format!("LAN: {ip}"));
values.push(ip.clone()); }
(labels, values)
}
fn lang_code_to_name(code: &str) -> &'static str {
match code {
"zh" => "Chinese",
"fr" => "French",
"de" => "German",
"ja" => "Japanese",
"ko" => "Korean",
"es" => "Spanish",
"ru" => "Russian",
"th" => "Thai",
"vi" => "Vietnamese",
_ => "English",
}
}
fn builtin_defaults() -> String {
rsclaw_config::loader::embedded_defaults_toml().to_owned()
}
use rsclaw_config::catalog::{
Catalog as Defaults, ChannelDef, ChannelFieldDef, ProviderDef, SearchEngineDef,
};
fn initial_onboard_api_type() -> String {
String::new()
}
fn load_defaults() -> Defaults {
let builtin: Defaults =
toml::from_str(&builtin_defaults()).expect("built-in defaults.toml is invalid");
let user_path = rsclaw_config::loader::base_dir().join("defaults.toml");
if let Ok(content) = std::fs::read_to_string(&user_path)
&& let Ok(user) = toml::from_str::<Defaults>(&content)
{
Defaults {
providers: if user.providers.is_empty() {
builtin.providers
} else {
user.providers
},
channels: if user.channels.is_empty() {
builtin.channels
} else {
user.channels
},
search_engines: if user.search_engines.is_empty() {
builtin.search_engines
} else {
user.search_engines
},
}
} else {
builtin
}
}
struct ExistingConfig {
agent_name: String,
provider_idx: usize,
api_key_display: String,
base_url: String,
model: String,
port: u16,
bind_idx: usize,
enabled_channels: Vec<String>,
provider_models: std::collections::HashMap<String, String>,
}
fn load_defaults_for_lang(lang: &str) -> Defaults {
let mut defs = load_defaults();
let priority: &[&str] = if lang == "zh" {
&["rsclaw", "qwen", "deepseek", "doubao"]
} else {
&["rsclaw"]
};
defs.providers.sort_by_key(|p| {
priority
.iter()
.position(|n| n == &p.name)
.unwrap_or(priority.len())
});
defs
}
fn load_existing_defaults(defs: &Defaults) -> ExistingConfig {
let mut ec = ExistingConfig {
agent_name: "main".into(),
provider_idx: 0,
api_key_display: String::new(),
base_url: String::new(),
model: String::new(),
port: 18888,
bind_idx: 0,
enabled_channels: vec![],
provider_models: std::collections::HashMap::new(),
};
let Ok((_, val)) = load_config_json() else {
return ec;
};
if let Some(p) = get_nested_value(&val, "gateway.port").and_then(|v| v.as_u64()) {
ec.port = p as u16;
}
let bind_options = ["loopback", "all"];
if let Some(b) =
get_nested_value(&val, "gateway.bind").and_then(|v| v.as_str().map(|s| s.to_owned()))
{
ec.bind_idx = bind_options.iter().position(|&x| x == b).unwrap_or(0);
}
if let Some(arr) = val
.get("agents")
.and_then(|a| a.get("list"))
.and_then(|l| l.as_array())
{
if let Some(first) = arr.first() {
if let Some(id) = first.get("id").and_then(|v| v.as_str()) {
ec.agent_name = id.to_owned();
}
if let Some(m) = first
.get("model")
.and_then(|m| m.get("primary"))
.and_then(|p| p.as_str())
{
ec.model = m.to_owned();
}
}
}
if ec.model.is_empty() {
if let Some(m) = get_nested_value(&val, "agents.defaults.model.primary")
.and_then(|v| v.as_str().map(|s| s.to_owned()))
{
ec.model = m;
}
}
let model_provider_prefix = ec.model.split('/').next().unwrap_or("").to_owned();
if let Some(obj) =
get_nested_value(&val, "models.providers").and_then(|v| v.as_object().cloned())
{
let pos = if !model_provider_prefix.is_empty() {
defs.providers
.iter()
.position(|p| p.name == model_provider_prefix && obj.contains_key(&p.name))
} else {
None
};
let pos = pos.or_else(|| {
defs.providers
.iter()
.position(|p| obj.contains_key(&p.name))
});
if let Some(pos) = pos {
ec.provider_idx = pos;
let prov = &defs.providers[pos];
let key_path = format!("models.providers.{}.apiKey", prov.name);
if let Some(k) =
get_nested_value(&val, &key_path).and_then(|v| v.as_str().map(|s| s.to_owned()))
{
if k.starts_with("${") {
ec.api_key_display = k;
} else if k.len() > 8 {
let start: String = k.chars().take(4).collect();
let end: String = k
.chars()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
ec.api_key_display = format!("{start}...{end}");
} else if !k.is_empty() {
ec.api_key_display = "*".repeat(k.len().min(20));
}
}
let url_path = format!("models.providers.{}.baseUrl", prov.name);
if let Some(u) =
get_nested_value(&val, &url_path).and_then(|v| v.as_str().map(|s| s.to_owned()))
{
ec.base_url = u;
}
}
}
if let Some(models_obj) = val
.pointer("/agents/defaults/models")
.and_then(|v| v.as_object())
{
for (model_key, _) in models_obj {
if let Some((prov, _)) = model_key.split_once('/') {
ec.provider_models
.insert(prov.to_owned(), model_key.clone());
}
}
}
if !ec.model.is_empty() {
if let Some((prov, _)) = ec.model.split_once('/') {
ec.provider_models.insert(prov.to_owned(), ec.model.clone());
}
}
if let Some(ch_obj) = val.get("channels").and_then(|v| v.as_object()) {
for (name, _) in ch_obj {
ec.enabled_channels.push(name.clone());
}
}
ec
}
pub async fn cmd_setup(args: SetupArgs) -> Result<()> {
if args.wizard {
return cmd_onboard(OnboardArgs::default()).await;
}
if args.non_interactive {
let base = rsclaw_config::loader::base_dir();
std::fs::create_dir_all(&base)?;
let config_path = base.join("rsclaw.json5");
if !config_path.exists() {
let token = generate_auth_token();
let body =
format!("{{\n gateway: {{\n auth: {{ token: \"{token}\" }},\n }},\n}}\n");
std::fs::write(&config_path, body)?;
}
for dir in &[
"var/data/redb",
"var/data/search",
"var/data/memory",
"var/data/cron",
"var/run",
"var/logs",
"var/cache",
"skills",
"models",
"plugins",
"workspace",
] {
let _ = std::fs::create_dir_all(base.join(dir));
}
let defaults_path = base.join("defaults.toml");
if !defaults_path.exists() {
let _ = std::fs::write(&defaults_path, &builtin_defaults());
}
let workspace = base.join("workspace");
let _ = rsclaw_agent::bootstrap::seed_workspace(&workspace);
if let Err(e) = rsclaw_agent::bootstrap::seed_tools(&base, None) {
tracing::warn!(error = %e, "non-interactive setup: seed_tools failed; site-rule library may be empty");
}
return Ok(());
}
let lang = select_language()?;
rsclaw_i18n::set_default_lang(lang);
header(&rsclaw_i18n::t("cli_setup_title", lang));
let home = dirs_next::home_dir().unwrap_or_default();
let openclaw_dir = home.join(".openclaw");
let openclaw_config = openclaw_dir.join("openclaw.json");
let mut session_count = 0usize;
let migrate_mode = if openclaw_config.exists() && std::env::var("RSCLAW_BASE_DIR").is_err() {
let scan = rsclaw_migrate::openclaw::scan_openclaw(&openclaw_dir).ok();
session_count = scan.as_ref().map(|s| s.total_sessions).unwrap_or(0);
let jsonl_count = scan.as_ref().map(|s| s.total_jsonl_files).unwrap_or(0);
let agent_count = scan.as_ref().map(|s| s.agent_ids.len()).unwrap_or(0);
step(
"*",
&rsclaw_i18n::t_fmt(
"cli_detected_openclaw",
rsclaw_i18n::default_lang(),
&[("path", &openclaw_dir.display().to_string())],
),
);
if session_count > 0 {
let lang = rsclaw_i18n::default_lang();
step(
" ",
&format!(
" {}",
rsclaw_i18n::t_fmt(
"cli_data_summary",
lang,
&[
("agents", &agent_count.to_string()),
("sessions", &session_count.to_string()),
("jsonl", &jsonl_count.to_string()),
]
)
),
);
}
println!();
let lang = rsclaw_i18n::default_lang();
let import_desc = rsclaw_i18n::t("cli_import_desc", lang);
let fresh_desc = rsclaw_i18n::t("cli_fresh_desc", lang);
let options: Vec<&str> = vec![import_desc.as_str(), fresh_desc.as_str()];
let migration_prompt = rsclaw_i18n::t("cli_migration_mode", lang);
match select_step(&migration_prompt, &options, 0) {
StepResult::Next(0) => Some(rsclaw_migrate::MigrateMode::Import),
StepResult::Next(_) => Some(rsclaw_migrate::MigrateMode::New),
StepResult::Back | StepResult::Cancel => {
println!(
" {}",
rsclaw_i18n::t("cli_setup_cancelled", rsclaw_i18n::default_lang())
);
return Ok(());
}
}
} else {
None
};
let base = {
let b = rsclaw_config::loader::base_dir();
let lang = rsclaw_i18n::default_lang();
if migrate_mode == Some(rsclaw_migrate::MigrateMode::Import) {
step(
"+",
&rsclaw_i18n::t_fmt(
"cli_import_data_to",
lang,
&[("path", &b.display().to_string())],
),
);
} else {
step(
"*",
&rsclaw_i18n::t_fmt("cli_using_dir", lang, &[("path", &b.display().to_string())]),
);
}
b
};
for dir in &[
"var/data/redb",
"var/data/search",
"var/data/memory",
"var/data/cron",
"var/run",
"var/logs",
"var/cache",
"skills",
"models",
"plugins",
] {
let path = base.join(dir);
std::fs::create_dir_all(&path)?;
step("+", &format!("{}", path.display()));
}
if migrate_mode == Some(rsclaw_migrate::MigrateMode::Import) {
step(
"*",
&rsclaw_i18n::t_fmt(
"cli_importing_sessions",
rsclaw_i18n::default_lang(),
&[("count", &session_count.to_string())],
),
);
match super::migrate::import_data_from(&openclaw_dir, &base).await {
Ok(()) => {
step(
"+",
&rsclaw_i18n::t("cli_converted_config", rsclaw_i18n::default_lang()),
);
}
Err(e) => {
step(
"!",
&rsclaw_i18n::t_fmt(
"cli_import_failed",
rsclaw_i18n::default_lang(),
&[("err", &e.to_string())],
),
);
}
}
}
let config_path = {
let p = base.join("rsclaw.json5");
if !p.exists() {
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&p, default_config(lang))?;
step("+", &format!("{}", p.display()));
} else {
step("=", &format!("{} (exists)", p.display()));
}
p
};
if config_path.exists() {
step("=", &format!("{} (config)", config_path.display()));
}
let defaults_path = base.join("defaults.toml");
if defaults_path.exists() {
step("=", &format!("{} (exists)", defaults_path.display()));
} else {
std::fs::write(&defaults_path, &builtin_defaults())?;
step("+", &format!("{}", defaults_path.display()));
}
let ws_lang = if lang == "zh" { Some("Chinese") } else { None };
let workspace = base.join("workspace");
let seeded = agent::seed_workspace_with_lang(&workspace, ws_lang)?;
if seeded > 0 {
step(
"+",
&rsclaw_i18n::t_fmt(
"cli_workspace_seeded",
lang,
&[
("count", &seeded.to_string()),
("path", &workspace.display().to_string()),
],
),
);
}
if let Ok(n) = agent::seed_tools(&base, ws_lang) {
if n > 0 {
step(
"+",
&format!("Seeded {n} tool prompt(s) in {}/tools/", base.display()),
);
}
}
step(
"+",
&rsclaw_i18n::t_fmt(
"cli_gateway_language_set",
lang,
&[("lang", lang_code_to_name(lang))],
),
);
let lang_final = rsclaw_i18n::default_lang();
done(&rsclaw_i18n::t("cli_setup_complete", lang_final));
println!();
if migrate_mode == Some(rsclaw_migrate::MigrateMode::Import) {
hint(&rsclaw_i18n::t("cli_then_start", lang_final));
} else {
hint(&rsclaw_i18n::t_fmt(
"cli_edit_config",
lang_final,
&[("path", &config_path.display().to_string())],
));
hint(if lang_final == "zh" {
"rsclaw onboard"
} else {
"rsclaw onboard"
});
}
println!();
Ok(())
}
pub async fn cmd_onboard(_args: OnboardArgs) -> Result<()> {
let lang = {
let configured_lang = rsclaw_config::load()
.ok()
.and_then(|c| c.raw.gateway.as_ref().and_then(|g| g.language.clone()));
if let Some(ref l) = configured_lang {
let resolved = rsclaw_i18n::resolve_lang(l);
rsclaw_i18n::set_default_lang(resolved);
resolved
} else {
select_language()?
}
};
println!();
let wiz_title = rsclaw_i18n::t("cli_setup_wizard_title", lang);
println!(" {wiz_title}");
println!(" {}", "=".repeat(wiz_title.len()));
println!();
hint(&rsclaw_i18n::t("cli_press_esc_back", lang));
let defs = load_defaults_for_lang(lang);
let ec = load_existing_defaults(&defs);
let provider_labels: Vec<&str> = defs.providers.iter().map(|p| p.label.as_str()).collect();
let mut agent_name = ec.agent_name;
let mut provider_idx = ec.provider_idx;
let mut api_key = String::new();
let mut base_url = ec.base_url;
let mut default_model = ec.model;
let mut port = ec.port;
let mut bind_mode = ec.bind_idx;
let mut channel_configs: Vec<(String, Vec<(String, String)>)> = Vec::new();
let mut custom_bind: Option<String> = None;
let mut api_type = initial_onboard_api_type(); let mut user_agent = String::new();
const STEP_AGENT: usize = 0;
const STEP_PROVIDER: usize = 1;
const STEP_API_TYPE: usize = 10; const STEP_BASE_URL: usize = 2;
const STEP_USER_AGENT: usize = 11; const STEP_API_KEY: usize = 3;
const STEP_MODEL: usize = 4;
const STEP_PORT: usize = 5;
const STEP_BIND: usize = 6;
const STEP_CHANNELS: usize = 7;
const STEP_DONE: usize = 99;
let mut wiz_step: usize = STEP_AGENT;
'outer: loop {
match wiz_step {
STEP_AGENT => {
header(&rsclaw_i18n::t("cli_step_agent", lang));
let agent_prompt = rsclaw_i18n::t("cli_agent_name", lang);
match input_step(&format!(" {agent_prompt}"), agent_name.clone()) {
StepResult::Next(val) => {
agent_name = val;
wiz_step = STEP_PROVIDER;
}
StepResult::Back | StepResult::Cancel => {
println!(" {}", rsclaw_i18n::t("cli_setup_cancelled", lang));
return Ok(());
}
}
}
STEP_PROVIDER => {
header(&rsclaw_i18n::t("cli_step_model_provider", lang));
let choose_prov = rsclaw_i18n::t("cli_choose_provider", lang);
match select_step(&format!(" {choose_prov}"), &provider_labels, provider_idx) {
StepResult::Next(idx) => {
provider_idx = idx;
let prov = &defs.providers[idx];
if prov.name == "custom"
|| prov.name == "codingplan"
|| prov.name == "doubao"
{
wiz_step = STEP_API_TYPE;
} else {
wiz_step = STEP_BASE_URL;
}
}
StepResult::Back => {
wiz_step = STEP_AGENT;
}
StepResult::Cancel => {
println!(" {}", rsclaw_i18n::t("cli_setup_cancelled", lang));
return Ok(());
}
}
}
STEP_API_TYPE => {
let api_labels = &[
"OpenAI Chat (default)",
"OpenAI Responses",
"Anthropic",
"Google Gemini",
"Ollama",
];
let api_values = &[
"openai",
"openai-responses",
"anthropic",
"gemini",
"ollama",
];
let prov = &defs.providers[provider_idx];
let preferred_default = prov.api_type.as_str();
let probe = if api_type.is_empty() {
preferred_default
} else {
api_type.as_str()
};
let current_idx = api_values.iter().position(|v| *v == probe).unwrap_or(0);
match select_step(" API Protocol", api_labels, current_idx) {
StepResult::Next(idx) => {
api_type = api_values[idx].to_string();
wiz_step = STEP_BASE_URL;
}
StepResult::Back => {
wiz_step = STEP_PROVIDER;
}
StepResult::Cancel => {
println!(" {}", rsclaw_i18n::t("cli_setup_cancelled", lang));
return Ok(());
}
}
}
STEP_USER_AGENT => {
let provider = &defs.providers[provider_idx];
let default_ua = if user_agent.is_empty() {
if provider.user_agent.is_empty() {
rsclaw_provider::DEFAULT_USER_AGENT.to_string()
} else {
provider.user_agent.clone()
}
} else {
user_agent.clone()
};
match input_step(" User-Agent header (blank for default)", default_ua) {
StepResult::Next(val) => {
user_agent = val;
wiz_step = STEP_API_KEY;
}
StepResult::Back => {
wiz_step = STEP_BASE_URL;
}
StepResult::Cancel => {
println!(" {}", rsclaw_i18n::t("cli_setup_cancelled", lang));
return Ok(());
}
}
}
STEP_BASE_URL => {
let provider = &defs.providers[provider_idx];
if provider.name == "ollama" {
match input_step(" Ollama base URL", provider.base_url.to_string()) {
StepResult::Next(val) => {
base_url = val;
wiz_step = STEP_MODEL;
}
StepResult::Back => {
wiz_step = STEP_PROVIDER;
}
StepResult::Cancel => {
println!(" {}", rsclaw_i18n::t("cli_setup_cancelled", lang));
return Ok(());
}
}
} else if provider.name == "custom" || provider.name == "codingplan" {
match input_step(" API base URL", base_url.clone()) {
StepResult::Next(val) => {
base_url = val;
wiz_step = STEP_USER_AGENT;
}
StepResult::Back => {
wiz_step = STEP_API_TYPE;
}
StepResult::Cancel => {
println!(" {}", rsclaw_i18n::t("cli_setup_cancelled", lang));
return Ok(());
}
}
} else if provider.name == "kimi" {
let default_url = if base_url.is_empty() {
provider.base_url.to_string()
} else {
base_url.clone()
};
match input_step(" Kimi API URL", default_url) {
StepResult::Next(val) => {
base_url = val;
wiz_step = STEP_API_KEY;
}
StepResult::Back => {
wiz_step = STEP_PROVIDER;
}
StepResult::Cancel => {
println!(" {}", rsclaw_i18n::t("cli_setup_cancelled", lang));
return Ok(());
}
}
} else if provider.name == "doubao" {
let default_url = if base_url.is_empty() {
provider.base_url.to_string()
} else {
base_url.clone()
};
match input_step(" Doubao API URL", default_url) {
StepResult::Next(val) => {
base_url = val;
wiz_step = STEP_API_KEY;
}
StepResult::Back => {
wiz_step = STEP_PROVIDER;
}
StepResult::Cancel => {
println!(" {}", rsclaw_i18n::t("cli_setup_cancelled", lang));
return Ok(());
}
}
} else {
base_url.clear();
wiz_step = STEP_API_KEY;
}
}
STEP_API_KEY => {
let provider = &defs.providers[provider_idx];
if provider.needs_key || provider.name == "custom" {
let prompt = if provider.name == "custom" {
" API key (blank if none required)".to_string()
} else {
let enter_key = rsclaw_i18n::t("cli_enter_api_key", lang);
format!(
" {} ({} - blank = env ${})",
provider.label, enter_key, provider.env_var
)
};
match password_step(&prompt) {
StepResult::Next(val) => {
api_key = val;
wiz_step = STEP_MODEL;
}
StepResult::Back => {
if provider.name == "custom" {
wiz_step = STEP_BASE_URL;
} else {
wiz_step = STEP_PROVIDER;
}
}
StepResult::Cancel => {
println!(" {}", rsclaw_i18n::t("cli_setup_cancelled", lang));
return Ok(());
}
}
} else {
api_key.clear();
wiz_step = STEP_MODEL;
}
}
STEP_MODEL => {
let provider = &defs.providers[provider_idx];
let model_default = if default_model.is_empty() {
if provider.name == "custom" {
"custom/your-model-id".to_string()
} else {
provider.model.to_string()
}
} else {
default_model.clone()
};
let model_prompt = rsclaw_i18n::t("cli_default_model", lang);
match input_step(&format!(" {model_prompt}"), model_default) {
StepResult::Next(val) => {
default_model = val;
wiz_step = STEP_PORT;
}
StepResult::Back => {
let prov = &defs.providers[provider_idx];
if prov.name == "ollama" {
wiz_step = STEP_BASE_URL;
} else if prov.name == "custom" || prov.name == "doubao" {
wiz_step = STEP_API_KEY;
} else if !prov.needs_key {
wiz_step = STEP_PROVIDER;
} else {
wiz_step = STEP_API_KEY;
}
}
StepResult::Cancel => {
println!(" {}", rsclaw_i18n::t("cli_setup_cancelled", lang));
return Ok(());
}
}
}
STEP_PORT => {
header(&rsclaw_i18n::t("cli_step_gateway", lang));
let port_prompt = rsclaw_i18n::t("cli_port", lang);
match input_step(&format!(" {port_prompt}"), port) {
StepResult::Next(val) => {
port = val;
wiz_step = STEP_BIND;
}
StepResult::Back => {
wiz_step = STEP_MODEL;
}
StepResult::Cancel => {
println!(" {}", rsclaw_i18n::t("cli_setup_cancelled", lang));
return Ok(());
}
}
}
STEP_BIND => {
let (bind_labels, bind_values_vec) = build_bind_options();
let bind_refs: Vec<&str> = bind_labels.iter().map(|s| s.as_str()).collect();
let bind_prompt = rsclaw_i18n::t("cli_bind_mode", lang);
match select_step(&format!(" {bind_prompt}"), &bind_refs, bind_mode) {
StepResult::Next(idx) => {
custom_bind = Some(bind_values_vec[idx].clone());
bind_mode = idx;
wiz_step = STEP_CHANNELS;
}
StepResult::Back => {
wiz_step = STEP_PORT;
}
StepResult::Cancel => {
println!(" {}", rsclaw_i18n::t("cli_setup_cancelled", lang));
return Ok(());
}
}
}
STEP_CHANNELS => {
let ch_header = rsclaw_i18n::t("cli_choose_channels", lang);
header(&rsclaw_i18n::t_fmt(
"cli_step_channels",
lang,
&[("label", &ch_header)],
));
loop {
let available: Vec<(usize, &str)> = defs
.channels
.iter()
.enumerate()
.filter(|(_, ch)| !channel_configs.iter().any(|(n, _)| *n == ch.name))
.map(|(i, ch)| (i, ch.label.as_str()))
.collect();
if available.is_empty() {
println!(" {}", rsclaw_i18n::t("cli_all_channels_configured", lang));
break;
}
let skip_done = rsclaw_i18n::t("cli_skip_done", lang);
let mut labels: Vec<&str> = vec![&skip_done];
labels.extend(available.iter().map(|(_, l)| *l));
let add_prompt = if channel_configs.is_empty() {
rsclaw_i18n::t("cli_add_channel", lang)
} else {
rsclaw_i18n::t("cli_add_another_channel", lang)
};
match select_step(&format!(" {add_prompt}"), &labels, 0) {
StepResult::Next(0) => break, StepResult::Next(sel) => {
let (ch_idx, _) = available[sel - 1];
let ch = defs.channels[ch_idx].clone();
match configure_one_channel(&ch).await {
ChannelResult::Done(f) => {
channel_configs.push((ch.name.clone(), f));
}
ChannelResult::Back => {
}
ChannelResult::Cancel => {
println!(" {}", rsclaw_i18n::t("cli_setup_cancelled", lang));
return Ok(());
}
}
}
StepResult::Back => {
if channel_configs.is_empty() {
wiz_step = STEP_BIND;
continue 'outer;
} else {
channel_configs.pop();
}
}
StepResult::Cancel => {
println!(" {}", rsclaw_i18n::t("cli_setup_cancelled", lang));
return Ok(());
}
}
}
wiz_step = STEP_DONE;
}
STEP_DONE => break,
_ => break,
}
}
let provider = &defs.providers[provider_idx];
let api_key_entry = if api_key.is_empty() && provider.needs_key {
format!("\"${{{}}}\"", provider.env_var)
} else if api_key.is_empty() {
"\"\"".to_string()
} else {
serde_json::to_string(&api_key)?
};
let (bind_str, bind_address) = if let Some(ref addr) = custom_bind {
match addr.as_str() {
"loopback" | "all" => (addr.as_str(), None),
ip => ("custom", Some(ip)),
}
} else {
match bind_mode {
0 => ("loopback", None),
1 => ("all", None),
_ => ("all", None),
}
};
let effective_base_url = if !base_url.is_empty() && base_url != provider.base_url {
base_url.clone()
} else {
String::new()
};
let base = rsclaw_config::loader::base_dir();
std::fs::create_dir_all(&base)?;
let config_path = resolve_config_path_for_write();
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let workspace_path = base
.join("workspace")
.display()
.to_string()
.replace('\\', "/");
let default_model_value = if default_model.contains('/') {
default_model.clone()
} else {
format!("{}/{default_model}", provider.name)
};
let mut val: serde_json::Value = if config_path.exists() {
std::fs::read_to_string(&config_path)
.ok()
.and_then(|raw| json5::from_str(&raw).ok())
.unwrap_or_else(|| json!({}))
} else {
json!({})
};
if !val.is_object() {
val = json!({});
}
let gateway = val
.as_object_mut()
.expect("config must be a JSON object")
.entry("gateway")
.or_insert_with(|| json!({}));
if let Some(gw) = gateway.as_object_mut() {
gw.insert("port".into(), json!(port));
gw.insert("bind".into(), json!(bind_str));
if let Some(ip) = bind_address {
gw.insert("bindAddress".into(), json!(ip));
}
}
if !val.get("models").is_some_and(|v| v.is_object()) {
val.as_object_mut()
.expect("config must be a JSON object")
.insert("models".into(), json!({}));
}
let models = val
.as_object_mut()
.expect("config must be a JSON object")
.get_mut("models")
.expect("models key must exist");
let providers_obj = models
.as_object_mut()
.expect("config must be a JSON object")
.entry("providers")
.or_insert_with(|| json!({}));
if let Some(provs) = providers_obj.as_object_mut() {
let prov_entry = provs
.entry(provider.name.clone())
.or_insert_with(|| json!({}));
if let Some(prov_obj) = prov_entry.as_object_mut() {
prov_obj.insert(
"apiKey".into(),
serde_json::from_str(&api_key_entry).unwrap_or_else(|_| json!(api_key_entry)),
);
if !effective_base_url.is_empty() {
prov_obj.insert("baseUrl".into(), json!(effective_base_url));
}
if provider.name == "doubao" {
let resolved = if api_type.is_empty() {
"openai-responses"
} else {
api_type.as_str()
};
prov_obj.insert("api".into(), json!(resolved));
} else if (provider.name == "custom" || provider.name == "codingplan")
&& !api_type.is_empty()
{
prov_obj.insert("api".into(), json!(api_type));
}
if !user_agent.is_empty() {
prov_obj.insert("userAgent".into(), json!(user_agent));
} else if !provider.user_agent.is_empty() {
prov_obj.insert("userAgent".into(), json!(provider.user_agent));
}
}
}
let agents = val
.as_object_mut()
.unwrap()
.entry("agents")
.or_insert_with(|| json!({}));
if let Some(agents_obj) = agents.as_object_mut() {
let list = agents_obj.entry("list").or_insert_with(|| json!([]));
if let Some(arr) = list.as_array_mut() {
if arr.is_empty() {
arr.push(json!({
"id": agent_name,
"workspace": workspace_path,
}));
} else {
let first = &mut arr[0];
if let Some(obj) = first.as_object_mut() {
obj.insert("id".into(), json!(agent_name));
obj.insert("workspace".into(), json!(workspace_path));
}
}
}
let defaults = agents_obj.entry("defaults").or_insert_with(|| json!({}));
if let Some(d_obj) = defaults.as_object_mut() {
let model_obj = d_obj.entry("model").or_insert_with(|| json!({}));
if let Some(m) = model_obj.as_object_mut() {
m.insert("primary".into(), json!(default_model_value));
}
}
}
if !channel_configs.is_empty() {
let channels = val
.as_object_mut()
.unwrap()
.entry("channels")
.or_insert_with(|| json!({}));
if let Some(ch_obj) = channels.as_object_mut() {
for (name, fields) in &channel_configs {
let mut entry = serde_json::Map::new();
entry.insert("enabled".into(), json!(true));
entry.insert("dmPolicy".into(), json!("pairing"));
entry.insert("groupPolicy".into(), json!("allowlist"));
for (k, v) in fields {
entry.insert(
k.clone(),
serde_json::from_str(v).unwrap_or_else(|_| json!(v)),
);
}
ch_obj.insert(name.clone(), serde_json::Value::Object(entry));
}
}
}
if val.pointer("/gateway/auth/token").is_none() {
let token = generate_auth_token();
if val.pointer("/gateway/auth").is_none() {
val["gateway"]["auth"] = json!({"token": token});
} else {
val["gateway"]["auth"]["token"] = json!(token);
}
info!("auto-generated gateway.auth.token");
}
let content = serde_json::to_string_pretty(&val)?;
if config_path.exists() {
rotate_backups(&config_path);
}
std::fs::write(&config_path, &content)?;
for dir in &[
"var/data/redb",
"var/data/search",
"var/data/memory",
"var/data/cron",
"var/run",
"var/logs",
"var/cache",
"skills",
"models",
"plugins",
] {
std::fs::create_dir_all(base.join(dir))?;
}
let defaults_path = base.join("defaults.toml");
if !defaults_path.exists() {
let _ = std::fs::write(&defaults_path, &builtin_defaults());
}
let workspace = base.join("workspace");
let _ = agent::seed_workspace(&workspace);
header(&rsclaw_i18n::t("cli_onboard_complete", lang));
step(
"*",
&rsclaw_i18n::t_fmt(
"cli_summary_config",
lang,
&[("path", &config_path.display().to_string())],
),
);
step(
"*",
&rsclaw_i18n::t_fmt(
"cli_summary_provider",
lang,
&[("label", &provider.label), ("name", &provider.name)],
),
);
step(
"*",
&rsclaw_i18n::t_fmt("cli_summary_model", lang, &[("model", &default_model)]),
);
step(
"*",
&rsclaw_i18n::t_fmt("cli_summary_agent", lang, &[("name", &agent_name)]),
);
step(
"*",
&rsclaw_i18n::t_fmt("cli_summary_port", lang, &[("port", &port.to_string())]),
);
if !channel_configs.is_empty() {
let names: Vec<&str> = channel_configs.iter().map(|(n, _)| n.as_str()).collect();
step(
"*",
&rsclaw_i18n::t_fmt(
"cli_summary_channels",
lang,
&[("names", &names.join(", "))],
),
);
}
println!();
hint(&rsclaw_i18n::t("cli_next_start", lang));
println!();
Ok(())
}
enum ChannelResult {
Done(Vec<(String, String)>),
Back,
Cancel,
}
async fn configure_one_channel(ch: &ChannelDef) -> ChannelResult {
let lang = rsclaw_i18n::default_lang();
println!();
println!(" -- {} --", ch.label);
if ch.login {
if ch.fields.is_empty() {
println!(" {}", rsclaw_i18n::t("cli_starting_login", lang));
match run_channel_login(&ch.name).await {
Ok(fields) => return ChannelResult::Done(fields),
Err(e) => {
println!(
" [!] {}",
rsclaw_i18n::t_fmt("cli_login_failed", lang, &[("err", &e.to_string())])
);
println!(
" {}",
rsclaw_i18n::t_fmt("cli_login_later", lang, &[("channel", &ch.name)])
);
return ChannelResult::Done(vec![]);
}
}
}
let opt_scan = rsclaw_i18n::t("cli_scan_oauth", lang);
let opt_manual = rsclaw_i18n::t("cli_manual_input", lang);
let options: Vec<&str> = vec![&opt_scan, &opt_manual];
let auth_prompt = rsclaw_i18n::t_fmt("cli_auth_method", lang, &[("label", &ch.label)]);
match select_step(&format!(" {auth_prompt}"), &options, 0) {
StepResult::Next(0) => match run_channel_login(&ch.name).await {
Ok(fields) => return ChannelResult::Done(fields),
Err(e) => {
println!(
" [!] {}",
rsclaw_i18n::t_fmt("cli_login_failed", lang, &[("err", &e.to_string())])
);
println!(" {}", rsclaw_i18n::t("cli_fallback_manual", lang));
}
},
StepResult::Next(_) => { }
StepResult::Back => return ChannelResult::Back,
StepResult::Cancel => return ChannelResult::Cancel,
}
}
if ch.fields.is_empty() {
return ChannelResult::Done(vec![]);
}
let mut fields = Vec::new();
let mut field_idx = 0;
while field_idx < ch.fields.len() {
let f = &ch.fields[field_idx];
let result = if f.secret {
password_step(&format!(" {}", f.prompt))
} else {
input_step(&format!(" {}", f.prompt), String::new())
};
match result {
StepResult::Next(val) => {
if !val.is_empty() {
fields.push((f.key.clone(), val));
}
field_idx += 1;
}
StepResult::Back => {
if field_idx == 0 {
return ChannelResult::Back;
}
if fields
.last()
.is_some_and(|(k, _)| *k == ch.fields[field_idx - 1].key)
{
fields.pop();
}
field_idx -= 1;
}
StepResult::Cancel => return ChannelResult::Cancel,
}
}
ChannelResult::Done(fields)
}
pub async fn cmd_configure(args: ConfigureArgs) -> Result<()> {
let (path, mut val) = load_config_json().map_err(|e| {
let err_str = format!("{e:#}");
let lang0 = rsclaw_i18n::default_lang();
if err_str.contains("no config file found") {
anyhow::anyhow!("{}", rsclaw_i18n::t("cli_no_config_found", lang0))
} else {
anyhow::anyhow!(
"{}",
rsclaw_i18n::t_fmt("cli_config_parse_failed", lang0, &[("err", &err_str)])
)
}
})?;
if let Ok(config) = rsclaw_config::load() {
if let Some(lang) = config
.raw
.gateway
.as_ref()
.and_then(|g| g.language.as_deref())
{
rsclaw_i18n::set_default_lang(lang);
}
}
let lang = rsclaw_i18n::default_lang();
header(&rsclaw_i18n::t("cli_configure_title", lang));
step(
"*",
&rsclaw_i18n::t_fmt(
"cli_editing",
lang,
&[("path", &path.display().to_string())],
),
);
hint(&rsclaw_i18n::t("cli_press_esc", lang));
let defs = load_defaults_for_lang(lang);
let mut ec = load_existing_defaults(&defs);
let original = val.clone();
if !args.section.is_empty() {
for section in &args.section {
match section.as_str() {
"gateway" => configure_gateway(&mut val, &ec).await?,
"model" | "provider" => configure_model(&mut val, &defs, &mut ec).await?,
"channels" => configure_channels(&mut val, &defs).await?,
"search" | "web" | "websearch" => configure_web_search(&mut val).await?,
"upload" | "limits" => configure_upload_limits(&mut val).await?,
"safety" | "exec" => configure_exec_safety(&mut val).await?,
other => println!(
" {}",
rsclaw_i18n::t_fmt("cli_unknown_section", lang, &[("name", other)])
),
}
}
} else {
let mut last_idx: usize = 1; let mut at_save_exit = false; loop {
let s_save = rsclaw_i18n::t("cli_save_exit", lang);
let s_gw = rsclaw_i18n::t("cli_gateway", lang);
let s_mp = rsclaw_i18n::t("cli_model_provider", lang);
let s_ch = rsclaw_i18n::t("cli_channels", lang);
let s_ws = rsclaw_i18n::t("cli_web_search", lang);
let s_ul = rsclaw_i18n::t("cli_upload_limits", lang);
let s_es = rsclaw_i18n::t("cli_exec_safety", lang);
let sections: Vec<&str> = vec![&s_save, &s_gw, &s_mp, &s_ch, &s_ws, &s_ul, &s_es];
let section_prompt = rsclaw_i18n::t("cli_configure_section", lang);
match select_step(&format!(" {section_prompt}"), §ions, last_idx) {
StepResult::Next(0) => break, StepResult::Next(idx) => {
last_idx = idx;
at_save_exit = false;
match idx {
1 => configure_gateway(&mut val, &ec).await?,
2 => configure_model(&mut val, &defs, &mut ec).await?,
3 => configure_channels(&mut val, &defs).await?,
4 => configure_web_search(&mut val).await?,
5 => configure_upload_limits(&mut val).await?,
6 => configure_exec_safety(&mut val).await?,
_ => {}
}
}
StepResult::Back | StepResult::Cancel => {
if at_save_exit {
println!();
println!(" {}", rsclaw_i18n::t("cli_cancelled", lang));
println!();
return Ok(());
}
last_idx = 0;
at_save_exit = true;
continue;
}
}
}
}
for ch in &defs.channels {
if !account_names(&val, &ch.name).is_empty() {
strip_top_fields(&mut val, &ch.name, &ch.fields);
}
}
if val == original {
println!();
println!(" {}", rsclaw_i18n::t("cli_no_changes", lang));
println!();
} else {
rotate_backups(&path);
std::fs::write(&path, serde_json::to_string_pretty(&val)?)?;
done(&rsclaw_i18n::t_fmt(
"cli_saved_to",
lang,
&[("path", &path.display().to_string())],
));
let pid_file = crate::cmd::gateway::gateway_pid_file();
let gateway_running = pid_file.exists()
&& std::fs::read_to_string(&pid_file)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.is_some_and(|pid| rsclaw_platform::process_alive(pid));
if gateway_running {
hint(&rsclaw_i18n::t("cli_restarting_gateway", lang));
if let Ok(pid_str) = std::fs::read_to_string(&pid_file)
&& let Ok(pid) = pid_str.trim().parse::<u32>()
{
let _ = rsclaw_platform::process_terminate(pid);
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
match crate::cmd::gateway::spawn_gateway_bg_pub() {
Ok(_) => done(&rsclaw_i18n::t("cli_gateway_restarted", lang)),
Err(e) => hint(&rsclaw_i18n::t_fmt(
"cli_restart_failed",
lang,
&[("err", &e.to_string())],
)),
}
}
println!();
}
Ok(())
}
async fn configure_gateway(val: &mut serde_json::Value, ec: &ExistingConfig) -> Result<()> {
let lang = rsclaw_i18n::default_lang();
header(&rsclaw_i18n::t("cli_section_gateway", lang));
let current_port = get_nested_value(val, "gateway.port")
.and_then(|v| v.as_u64())
.unwrap_or(ec.port as u64) as u16;
let bind_options = ["loopback", "all"];
let current_bind = get_nested_value(val, "gateway.bind")
.and_then(|v| v.as_str().map(|s| s.to_owned()))
.unwrap_or_else(|| "loopback".into());
let current_bind_idx = bind_options
.iter()
.position(|&b| b == current_bind)
.unwrap_or(0);
let port_prompt = rsclaw_i18n::t("cli_port", lang);
let new_port = match input_step(&format!(" {port_prompt}"), current_port) {
StepResult::Next(v) => v,
StepResult::Back | StepResult::Cancel => return Ok(()),
};
let (bind_labels, bind_values) = build_bind_options();
let bind_refs: Vec<&str> = bind_labels.iter().map(|s| s.as_str()).collect();
let bind_prompt = rsclaw_i18n::t("cli_bind_mode", lang);
let new_bind_value =
match select_step(&format!(" {bind_prompt}"), &bind_refs, current_bind_idx) {
StepResult::Next(idx) => bind_values[idx].clone(),
StepResult::Back | StepResult::Cancel => return Ok(()),
};
if new_port != current_port {
ensure_json_path(val, &["gateway"]);
set_nested_value(val, "gateway.port", serde_json::json!(new_port))?;
}
ensure_json_path(val, &["gateway"]);
let is_ip = new_bind_value.parse::<std::net::IpAddr>().is_ok();
if is_ip {
set_nested_value(val, "gateway.bind", serde_json::json!("custom"))?;
set_nested_value(
val,
"gateway.bindAddress",
serde_json::json!(new_bind_value),
)?;
} else {
set_nested_value(val, "gateway.bind", serde_json::json!(new_bind_value))?;
if let Some(obj) = val.pointer_mut("/gateway").and_then(|v| v.as_object_mut()) {
obj.remove("bindAddress");
}
}
Ok(())
}
async fn configure_model(
val: &mut serde_json::Value,
defs: &Defaults,
ec: &mut ExistingConfig,
) -> Result<()> {
let lang = rsclaw_i18n::default_lang();
header(&rsclaw_i18n::t("cli_section_model_provider", lang));
let provider_labels: Vec<&str> = defs.providers.iter().map(|p| p.label.as_str()).collect();
let mut provider_idx = ec.provider_idx;
let current_model = ec.model.clone();
let mut new_model = if current_model.is_empty() {
defs.providers[provider_idx].model.clone()
} else {
current_model.clone()
};
let prov_prompt = rsclaw_i18n::t("cli_provider", lang);
match select_step(&format!(" {prov_prompt}"), &provider_labels, provider_idx) {
StepResult::Next(idx) => {
if idx != provider_idx {
if !new_model.is_empty() {
let cur_prov = &defs.providers[provider_idx].name;
let save_model = if new_model.contains('/') {
new_model.clone()
} else {
format!("{cur_prov}/{new_model}")
};
ec.provider_models.insert(cur_prov.clone(), save_model);
}
let prov_name = &defs.providers[idx].name;
new_model = ec
.provider_models
.get(prov_name.as_str())
.cloned()
.unwrap_or_default();
}
provider_idx = idx;
}
StepResult::Back | StepResult::Cancel => return Ok(()),
}
let provider = &defs.providers[provider_idx];
let new_base_url;
let mut change_key = false;
let mut new_key = String::new();
if provider.name == "ollama" {
let current = get_nested_value(val, &format!("models.providers.{}.baseUrl", provider.name))
.and_then(|v| v.as_str().map(|s| s.to_owned()))
.unwrap_or_else(|| provider.base_url.to_string());
match input_step(" Ollama base URL", current) {
StepResult::Next(u) => new_base_url = u,
StepResult::Back | StepResult::Cancel => return Ok(()),
}
} else if provider.name == "custom" || provider.name == "codingplan" {
let current = get_nested_value(val, &format!("models.providers.{}.baseUrl", provider.name))
.and_then(|v| v.as_str().map(|s| s.to_owned()))
.unwrap_or_default();
match input_step(" API base URL", current) {
StepResult::Next(u) => new_base_url = u,
StepResult::Back | StepResult::Cancel => return Ok(()),
}
} else if provider.name == "doubao" {
let current = get_nested_value(val, "models.providers.doubao.baseUrl")
.and_then(|v| v.as_str().map(|s| s.to_owned()))
.unwrap_or_else(|| provider.base_url.to_string());
match input_step(" Doubao API URL", current) {
StepResult::Next(u) => new_base_url = u,
StepResult::Back | StepResult::Cancel => return Ok(()),
}
} else {
new_base_url = provider.base_url.to_string();
}
let mut new_api_type = String::new();
let mut new_user_agent = String::new();
let supports_api_type =
provider.name == "custom" || provider.name == "codingplan" || provider.name == "doubao";
if supports_api_type {
let api_labels = &[
"OpenAI Chat (default)",
"OpenAI Responses",
"Anthropic",
"Google Gemini",
"Ollama",
];
let api_values = &[
"openai",
"openai-responses",
"anthropic",
"gemini",
"ollama",
];
let fallback_default = provider.api_type.as_str();
let current_api = get_nested_value(val, &format!("models.providers.{}.api", provider.name))
.and_then(|v| v.as_str().map(|s| s.to_owned()))
.unwrap_or_else(|| fallback_default.to_string());
let current_idx = api_values
.iter()
.position(|v| *v == current_api)
.unwrap_or(0);
match select_step(" API Protocol", api_labels, current_idx) {
StepResult::Next(idx) => {
new_api_type = api_values[idx].to_string();
}
StepResult::Back | StepResult::Cancel => return Ok(()),
}
if provider.name == "custom" || provider.name == "codingplan" {
let current_ua = get_nested_value(
val,
&format!("models.providers.{}.userAgent", provider.name),
)
.and_then(|v| v.as_str().map(|s| s.to_owned()))
.unwrap_or_else(|| {
if provider.user_agent.is_empty() {
rsclaw_provider::DEFAULT_USER_AGENT.to_string()
} else {
provider.user_agent.clone()
}
});
match input_step(" User-Agent header", current_ua) {
StepResult::Next(ua) => {
new_user_agent = ua;
}
StepResult::Back | StepResult::Cancel => return Ok(()),
}
}
}
if provider.needs_key || provider.name == "custom" || provider.name == "codingplan" {
let api_key_path = format!("models.providers.{}.apiKey", provider.name);
let current_key_display = get_nested_value(val, &api_key_path)
.and_then(|v| v.as_str().map(|s| s.to_owned()))
.map(|s| {
if s.starts_with("${") {
s
} else if s.chars().count() > 8 {
let prefix: String = s.chars().take(4).collect();
let suffix: String = s
.chars()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("{prefix}...{suffix}")
} else {
"*".repeat(s.len().min(20))
}
})
.unwrap_or_else(|| rsclaw_i18n::t("cli_not_set", lang));
step(
"*",
&rsclaw_i18n::t_fmt("cli_current_key", lang, &[("key", ¤t_key_display)]),
);
let change_prompt = rsclaw_i18n::t("cli_change_api_key", lang);
match confirm_step(&format!(" {change_prompt}"), false) {
StepResult::Next(true) => {
change_key = true;
match password_step(&format!(
" {} API key (blank = env ${})",
provider.label, provider.env_var
)) {
StepResult::Next(k) => new_key = k,
StepResult::Back | StepResult::Cancel => return Ok(()),
}
}
StepResult::Next(false) => {}
StepResult::Back | StepResult::Cancel => return Ok(()),
}
}
let model_default = if !new_model.is_empty() {
new_model.clone()
} else if !provider.model.is_empty() {
provider.model.clone()
} else {
format!("{}/your-model-id", provider.name)
};
let model_prompt = rsclaw_i18n::t("cli_default_model", lang);
match input_step(&format!(" {model_prompt}"), model_default) {
StepResult::Next(m) => new_model = m,
StepResult::Back | StepResult::Cancel => return Ok(()),
}
let test_url = if !new_base_url.is_empty() {
new_base_url.clone()
} else {
provider.base_url.clone()
};
let test_key = if change_key && !new_key.is_empty() {
Some(new_key.clone())
} else {
get_nested_value(val, &format!("models.providers.{}.apiKey", provider.name))
.and_then(|v| v.as_str().map(|s| s.to_owned()))
.filter(|k| !k.starts_with("${") && !k.is_empty())
.or_else(|| {
std::env::var(if provider.env_var.is_empty() {
"_NONE_"
} else {
&provider.env_var
})
.ok()
})
};
if !test_url.is_empty()
|| provider.name == "anthropic"
|| provider.name == "openai"
|| provider.name == "gemini"
{
step("*", &rsclaw_i18n::t("cli_testing_connectivity", lang));
let effective_api_type: Option<String> = if !new_api_type.is_empty() {
Some(new_api_type.clone())
} else {
get_nested_value(val, &format!("models.providers.{}.api", provider.name))
.and_then(|v| v.as_str().map(|s| s.to_owned()))
};
let probe_models: Vec<String> = new_model
.split(|c| c == ',' || c == '\u{FF0C}')
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.collect();
let mut any_failed = false;
for probe_model in &probe_models {
match test_provider_connectivity(
&test_url,
test_key.as_deref(),
&provider.name,
effective_api_type.as_deref(),
Some(probe_model),
)
.await
{
Ok(()) => {
if probe_models.len() == 1 {
step("*", &rsclaw_i18n::t("cli_connection_ok", lang));
} else {
println!(" [ok] {probe_model}");
}
}
Err(e) => {
any_failed = true;
if probe_models.len() == 1 {
println!(
" [!] {}",
rsclaw_i18n::t_fmt(
"cli_connection_failed",
lang,
&[("err", &e.to_string())],
)
);
} else {
println!(" [!] {probe_model}: {e}");
}
}
}
}
if any_failed {
println!(" {}", rsclaw_i18n::t("cli_fix_later", lang));
}
}
if change_key && (provider.needs_key || provider.name == "custom") {
let api_key_path = format!("models.providers.{}.apiKey", provider.name);
let key_val = if new_key.is_empty() && !provider.env_var.is_empty() {
format!("${{{}}}", provider.env_var)
} else {
new_key
};
ensure_json_path(val, &["models"]);
ensure_json_path(val, &["models", "providers"]);
ensure_json_path(val, &["models", "providers", &provider.name]);
set_nested_value(val, &api_key_path, serde_json::json!(key_val))?;
}
if !new_base_url.is_empty() && new_base_url != provider.base_url {
let url_path = format!("models.providers.{}.baseUrl", provider.name);
ensure_json_path(val, &["models"]);
ensure_json_path(val, &["models", "providers"]);
ensure_json_path(val, &["models", "providers", &provider.name]);
set_nested_value(val, &url_path, serde_json::json!(new_base_url))?;
} else if new_base_url == provider.base_url || new_base_url.is_empty() {
if let Some(prov_obj) = val
.pointer_mut(&format!("/models/providers/{}", provider.name))
.and_then(|v| v.as_object_mut())
{
if prov_obj
.get("baseUrl")
.and_then(|v| v.as_str())
.map(|s| s == provider.base_url)
.unwrap_or(false)
{
prov_obj.remove("baseUrl");
}
}
}
if !new_api_type.is_empty() {
let api_path = format!("models.providers.{}.api", provider.name);
ensure_json_path(val, &["models", "providers", &provider.name]);
set_nested_value(val, &api_path, serde_json::json!(new_api_type))?;
}
if !new_user_agent.is_empty() {
let ua_path = format!("models.providers.{}.userAgent", provider.name);
ensure_json_path(val, &["models", "providers", &provider.name]);
set_nested_value(val, &ua_path, serde_json::json!(new_user_agent))?;
}
let model_entries: Vec<String> = new_model
.split(|c| c == ',' || c == '\u{FF0C}')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| {
if s.contains('/') {
s.to_owned()
} else {
format!("{}/{s}", provider.name)
}
})
.collect();
let final_model_value: serde_json::Value = match model_entries.len() {
0 => serde_json::Value::Null,
1 => serde_json::json!(model_entries[0]),
_ => serde_json::json!(model_entries),
};
let final_model_head = model_entries.first().cloned().unwrap_or_default();
if !final_model_head.is_empty() && final_model_head != current_model {
if let Some(arr) = val
.get_mut("agents")
.and_then(|a| a.get_mut("list"))
.and_then(|l| l.as_array_mut())
&& let Some(agent) = arr.first_mut()
&& let Some(m) = agent.get_mut("model").and_then(|m| m.as_object_mut())
{
m.insert("primary".to_string(), final_model_value.clone());
}
ensure_json_path(val, &["agents"]);
ensure_json_path(val, &["agents", "defaults"]);
ensure_json_path(val, &["agents", "defaults", "model"]);
set_nested_value(val, "agents.defaults.model.primary", final_model_value)?;
ensure_json_path(val, &["agents", "defaults", "models"]);
if let Some(models_obj) = val
.pointer_mut("/agents/defaults/models")
.and_then(|v| v.as_object_mut())
{
models_obj.insert(
final_model_head.clone(),
serde_json::json!({ "alias": provider.name }),
);
}
}
ec.provider_idx = provider_idx;
ec.model = final_model_head;
Ok(())
}
fn get_channel_enabled(val: &serde_json::Value, ch_name: &str) -> bool {
val.get("channels")
.and_then(|c| c.get(ch_name))
.map(|ch| ch.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true))
.unwrap_or(false) }
fn toggle_channel_enabled(val: &mut serde_json::Value, ch_name: &str, enabled: bool) {
ensure_json_path(val, &["channels"]);
ensure_json_path(val, &["channels", ch_name]);
if let Some(ch) = val.get_mut("channels").and_then(|c| c.get_mut(ch_name)) {
if let Some(obj) = ch.as_object_mut() {
obj.insert("enabled".to_string(), serde_json::json!(enabled));
obj.entry("dmPolicy")
.or_insert(serde_json::json!("pairing"));
obj.entry("groupPolicy")
.or_insert(serde_json::json!("allowlist"));
}
}
}
fn channel_is_configured(val: &serde_json::Value, ch_name: &str) -> bool {
val.get("channels")
.and_then(|c| c.get(ch_name))
.and_then(|ch| ch.as_object())
.is_some_and(|obj| {
obj.iter().any(|(k, v)| {
if k == "enabled" {
return false;
}
if k == "accounts" {
return v.as_object().is_some_and(|a| !a.is_empty());
}
true
})
})
}
fn account_names(val: &serde_json::Value, ch_name: &str) -> Vec<String> {
val.get("channels")
.and_then(|c| c.get(ch_name))
.and_then(|ch| ch.get("accounts"))
.and_then(|a| a.as_object())
.map(|m| {
let mut names: Vec<String> = m.keys().cloned().collect();
names.sort();
names
})
.unwrap_or_default()
}
fn derive_account_name(
ch_name: &str,
fields: &[(String, String)],
existing: &[String],
) -> String {
let base = fields
.iter()
.find(|(k, _)| k == "appId" || k == "botId")
.map(|(_, v)| {
let trimmed = v.trim().trim_start_matches("cli_");
let short: String = trimmed
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(8)
.collect();
if short.is_empty() {
format!("{ch_name}-account")
} else {
format!("{ch_name}-{short}")
}
})
.unwrap_or_else(|| format!("{ch_name}-account"));
if !existing.iter().any(|e| e == &base) {
return base;
}
let mut n = 2;
loop {
let candidate = format!("{base}-{n}");
if !existing.iter().any(|e| e == &candidate) {
return candidate;
}
n += 1;
}
}
fn has_top_level_fields(
val: &serde_json::Value,
ch_name: &str,
fields: &[ChannelFieldDef],
) -> bool {
fields.iter().any(|f| {
let path = format!("channels.{}.{}", ch_name, f.key);
get_nested_value(val, &path)
.and_then(|v| v.as_str())
.is_some_and(|s| !s.is_empty())
})
}
fn strip_top_fields(val: &mut serde_json::Value, ch_name: &str, fields: &[ChannelFieldDef]) {
for f in fields {
remove_nested_value(val, &format!("channels.{}.{}", ch_name, f.key));
}
}
fn migrate_top_to_accounts(
val: &mut serde_json::Value,
ch_name: &str,
fields: &[ChannelFieldDef],
account_name: &str,
) {
ensure_json_path(val, &["channels"]);
ensure_json_path(val, &["channels", ch_name]);
ensure_json_path(val, &["channels", ch_name, "accounts"]);
ensure_json_path(val, &["channels", ch_name, "accounts", account_name]);
for f in fields {
let src = format!("channels.{}.{}", ch_name, f.key);
let Some(v) = get_nested_value(val, &src).cloned() else {
continue;
};
let dst = format!("channels.{}.accounts.{}.{}", ch_name, account_name, f.key);
let _ = set_nested_value(val, &dst, v);
remove_nested_value(val, &src);
}
}
fn remove_account(val: &mut serde_json::Value, ch_name: &str, acct: &str) {
let acct_path = format!("channels.{}.accounts.{}", ch_name, acct);
remove_nested_value(val, &acct_path);
let still_has_accts = val
.get("channels")
.and_then(|c| c.get(ch_name))
.and_then(|ch| ch.get("accounts"))
.and_then(|a| a.as_object())
.is_some_and(|m| !m.is_empty());
if !still_has_accts {
remove_nested_value(val, &format!("channels.{}.accounts", ch_name));
}
}
fn mask_secret(current: &str) -> String {
if current.starts_with("${") {
current.to_owned()
} else if current.chars().count() > 8 {
let prefix: String = current.chars().take(4).collect();
let suffix: String = current
.chars()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("{prefix}...{suffix}")
} else {
"*".repeat(current.len().min(8))
}
}
fn edit_fields_at_prefix(
val: &mut serde_json::Value,
fields: &[ChannelFieldDef],
prefix: &str,
lang: &str,
) -> bool {
let mut changed = false;
let prefix_parts: Vec<String> = prefix.split('.').map(|s| s.to_owned()).collect();
for field in fields {
let path = format!("{}.{}", prefix, field.key);
let current = get_nested_value(val, &path)
.and_then(|v| v.as_str().map(|s| s.to_owned()))
.unwrap_or_default();
let result = if field.secret && !current.is_empty() {
let masked = mask_secret(¤t);
let keep_label = rsclaw_i18n::t_fmt("cli_keep", lang, &[("value", &masked)]);
let edit_label = rsclaw_i18n::t("cli_edit", lang);
let back_label = rsclaw_i18n::t("cli_back", lang);
let items = &[
keep_label.as_str(),
edit_label.as_str(),
back_label.as_str(),
];
match Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(&format!(" {}", field.prompt))
.items(items)
.default(0)
.interact_opt()
{
Ok(Some(0)) => continue,
Ok(Some(1)) => password_step(&format!(" {}", field.prompt)),
_ => StepResult::Back,
}
} else {
input_step(&format!(" {}", field.prompt), current.clone())
};
match result {
StepResult::Next(new_val) => {
if new_val != current && !new_val.is_empty() {
let parts_ref: Vec<&str> = prefix_parts.iter().map(|s| s.as_str()).collect();
for i in 1..=parts_ref.len() {
ensure_json_path(val, &parts_ref[..i]);
}
let _ = set_nested_value(val, &path, serde_json::json!(new_val));
changed = true;
}
}
StepResult::Back | StepResult::Cancel => break,
}
}
changed
}
async fn edit_channel_config(val: &mut serde_json::Value, ch: &ChannelDef) -> bool {
let already_configured = ch.fields.iter().any(|f| {
let path = format!("channels.{}.{}", ch.name, f.key);
get_nested_value(val, &path)
.and_then(|v| v.as_str())
.is_some_and(|s| !s.is_empty())
}) || ch.multi_account && ch.fields.iter().any(|f| {
let path = format!("channels.{}.accounts.default.{}", ch.name, f.key);
get_nested_value(val, &path)
.and_then(|v| v.as_str())
.is_some_and(|s| !s.is_empty())
});
let lang = rsclaw_i18n::default_lang();
if ch.login {
if ch.fields.is_empty() && !already_configured {
println!(" {}", rsclaw_i18n::t("cli_starting_login", lang));
match run_channel_login(&ch.name).await {
Ok(_fields) => {
toggle_channel_enabled(val, &ch.name, true);
return true;
}
Err(e) => {
println!(
" [!] {}",
rsclaw_i18n::t_fmt("cli_login_failed", lang, &[("err", &e.to_string())])
);
println!(
" {}",
rsclaw_i18n::t_fmt("cli_login_later", lang, &[("channel", &ch.name)])
);
return false;
}
}
}
let default_idx = if already_configured { 1 } else { 0 };
let opt_scan = rsclaw_i18n::t("cli_scan_add", lang);
let opt_manual = rsclaw_i18n::t("cli_manual_edit", lang);
let opt_back = rsclaw_i18n::t("cli_back", lang);
let options_vec = [opt_scan.as_str(), opt_manual.as_str(), opt_back.as_str()];
let auth_prompt = rsclaw_i18n::t_fmt("cli_auth_method", lang, &[("label", &ch.label)]);
match select_step(&format!(" {auth_prompt}"), &options_vec, default_idx) {
StepResult::Next(0) => match run_channel_login(&ch.name).await {
Ok(fields) => {
if ch.multi_account {
let existing = account_names(val, &ch.name);
let acct_name = if existing.is_empty() {
"default".to_string()
} else {
derive_account_name(&ch.name, &fields, &existing)
};
ensure_json_path(
val,
&["channels", &ch.name, "accounts", &acct_name],
);
for (k, v) in &fields {
let path =
format!("channels.{}.accounts.{}.{}", ch.name, acct_name, k);
let _ = set_nested_value(val, &path, serde_json::json!(v));
}
toggle_channel_enabled(val, &ch.name, true);
println!(
" {}",
rsclaw_i18n::t_fmt(
"cli_account_added",
lang,
&[("name", &acct_name)],
)
);
edit_channel_policies(val, &ch.name, lang);
return true;
} else {
ensure_json_path(val, &["channels", &ch.name, "accounts", "default"]);
for (k, v) in &fields {
let path = format!("channels.{}.accounts.default.{}", ch.name, k);
let _ = set_nested_value(val, &path, serde_json::json!(v));
}
toggle_channel_enabled(val, &ch.name, true);
return true;
}
}
Err(e) => {
println!(
" [!] {}",
rsclaw_i18n::t_fmt("cli_login_failed", lang, &[("err", &e.to_string())])
);
println!(" {}", rsclaw_i18n::t("cli_fallback_manual", lang));
}
},
StepResult::Next(1) => { }
_ => return false,
}
}
if ch.fields.is_empty() {
println!(
" {}",
rsclaw_i18n::t_fmt("cli_no_fields", lang, &[("label", &ch.label)])
);
return false;
}
let mut changed = false;
let has_accounts = !account_names(val, &ch.name).is_empty();
let has_top = has_top_level_fields(val, &ch.name, &ch.fields);
if ch.multi_account && (has_accounts || has_top) {
let want_multi = if has_accounts {
true
} else {
prompt_add_another_account(&ch.label, lang)
};
if want_multi {
if has_top {
if has_accounts {
strip_top_fields(val, &ch.name, &ch.fields);
} else {
migrate_top_to_accounts(val, &ch.name, &ch.fields, "default");
}
changed = true;
}
if edit_channel_multi_account(val, ch, lang).await {
changed = true;
}
return changed;
}
}
let is_configured = has_top || has_accounts;
println!();
if is_configured {
println!(
" {}",
rsclaw_i18n::t_fmt("cli_config_enter_keep", lang, &[("label", &ch.label)])
);
} else {
println!(
" {}",
rsclaw_i18n::t_fmt("cli_config_label", lang, &[("label", &ch.label)])
);
}
if has_top {
if has_accounts {
strip_top_fields(val, &ch.name, &ch.fields);
} else {
migrate_top_to_accounts(val, &ch.name, &ch.fields, "default");
}
changed = true;
}
let prefix = format!("channels.{}.accounts.default", ch.name);
if edit_fields_at_prefix(val, &ch.fields, &prefix, lang) {
changed = true;
}
if edit_channel_policies(val, &ch.name, lang) {
changed = true;
}
changed
}
fn edit_channel_policies(val: &mut serde_json::Value, ch_name: &str, lang: &str) -> bool {
let dm = edit_dm_policy(val, ch_name, lang);
let gp = edit_group_policy(val, ch_name, lang);
dm || gp
}
fn current_dm_policy(val: &serde_json::Value, ch_name: &str) -> String {
get_nested_value(val, &format!("channels.{}.dmPolicy", ch_name))
.and_then(|v| v.as_str())
.unwrap_or("pairing")
.to_owned()
}
fn current_group_policy(val: &serde_json::Value, ch_name: &str) -> String {
get_nested_value(val, &format!("channels.{}.groupPolicy", ch_name))
.and_then(|v| v.as_str())
.unwrap_or("allowlist")
.to_owned()
}
fn edit_dm_policy(val: &mut serde_json::Value, ch_name: &str, lang: &str) -> bool {
let dm_path = format!("channels.{}.dmPolicy", ch_name);
let current_dm = current_dm_policy(val, ch_name);
let dm_policies = &["pairing", "open", "allowlist", "disabled"];
let dm_idx = dm_policies
.iter()
.position(|&p| p == current_dm)
.unwrap_or(0);
let dm_prompt = rsclaw_i18n::t_fmt("cli_dm_policy", lang, &[("policy", ¤t_dm)]);
if let StepResult::Next(idx) = select_step(&format!(" {dm_prompt}"), dm_policies, dm_idx) {
let new_policy = dm_policies[idx];
if new_policy != current_dm {
ensure_json_path(val, &["channels"]);
ensure_json_path(val, &["channels", ch_name]);
let _ = set_nested_value(val, &dm_path, serde_json::json!(new_policy));
return true;
}
}
false
}
fn edit_group_policy(val: &mut serde_json::Value, ch_name: &str, lang: &str) -> bool {
let gp_path = format!("channels.{}.groupPolicy", ch_name);
let current_gp = current_group_policy(val, ch_name);
let gp_policies = &["allowlist", "open", "disabled"];
let gp_idx = gp_policies
.iter()
.position(|&p| p == current_gp)
.unwrap_or(0);
let gp_prompt = rsclaw_i18n::t_fmt("cli_group_policy", lang, &[("policy", ¤t_gp)]);
if let StepResult::Next(idx) = select_step(&format!(" {gp_prompt}"), gp_policies, gp_idx) {
let new_policy = gp_policies[idx];
if new_policy != current_gp {
ensure_json_path(val, &["channels"]);
ensure_json_path(val, &["channels", ch_name]);
let _ = set_nested_value(val, &gp_path, serde_json::json!(new_policy));
return true;
}
}
false
}
fn prompt_add_another_account(label: &str, lang: &str) -> bool {
let keep = rsclaw_i18n::t("cli_account_keep_single", lang);
let add = rsclaw_i18n::t("cli_account_add_another", lang);
let prompt = rsclaw_i18n::t_fmt("cli_account_choose_mode", lang, &[("label", label)]);
matches!(
select_step(&format!(" {prompt}"), &[keep.as_str(), add.as_str()], 0),
StepResult::Next(1)
)
}
async fn edit_channel_multi_account(
val: &mut serde_json::Value,
ch: &ChannelDef,
lang: &str,
) -> bool {
let mut changed = false;
loop {
let names = account_names(val, &ch.name);
let add_label = rsclaw_i18n::t("cli_account_add_new", lang);
let dm_label = rsclaw_i18n::t_fmt(
"cli_dm_policy",
lang,
&[("policy", ¤t_dm_policy(val, &ch.name))],
);
let gp_label = rsclaw_i18n::t_fmt(
"cli_group_policy",
lang,
&[("policy", ¤t_group_policy(val, &ch.name))],
);
let back_label = rsclaw_i18n::t("cli_back", lang);
let dm_idx = names.len() + 1;
let gp_idx = names.len() + 2;
let back_idx = names.len() + 3;
let mut items: Vec<String> = names.clone();
items.push(add_label);
items.push(dm_label);
items.push(gp_label);
items.push(back_label);
let items_ref: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
let title = rsclaw_i18n::t_fmt("cli_account_list_title", lang, &[("label", &ch.label)]);
let sel = match select_step(&format!(" {title}"), &items_ref, 0) {
StepResult::Next(i) => i,
_ => break,
};
if sel == dm_idx {
if edit_dm_policy(val, &ch.name, lang) {
changed = true;
}
continue;
}
if sel == gp_idx {
if edit_group_policy(val, &ch.name, lang) {
changed = true;
}
continue;
}
if sel == back_idx {
break;
}
if sel == names.len() {
let name_prompt = rsclaw_i18n::t("cli_account_name_prompt", lang);
let new_name = match input_step(&format!(" {name_prompt}"), String::new()) {
StepResult::Next(s) => s.trim().to_owned(),
_ => continue,
};
if new_name.is_empty() {
continue;
}
if new_name.contains('.') || new_name.contains(' ') {
println!(" [!] {}", rsclaw_i18n::t("cli_account_name_invalid", lang));
continue;
}
if names.iter().any(|n| n == &new_name) {
println!(
" [!] {}",
rsclaw_i18n::t_fmt("cli_account_exists", lang, &[("name", &new_name)])
);
continue;
}
let prefix = format!("channels.{}.accounts.{}", ch.name, new_name);
let use_scan = if ch.login {
let opt_scan = rsclaw_i18n::t("cli_scan_add", lang);
let opt_manual = rsclaw_i18n::t("cli_manual_edit", lang);
let auth_prompt =
rsclaw_i18n::t_fmt("cli_auth_method", lang, &[("label", &ch.label)]);
matches!(
select_step(
&format!(" {auth_prompt}"),
&[opt_scan.as_str(), opt_manual.as_str()],
0,
),
StepResult::Next(0)
)
} else {
false
};
if use_scan {
match run_channel_login(&ch.name).await {
Ok(fields) => {
ensure_json_path(
val,
&["channels", &ch.name, "accounts", &new_name],
);
for (k, v) in &fields {
let path =
format!("channels.{}.accounts.{}.{}", ch.name, new_name, k);
let _ = set_nested_value(val, &path, serde_json::json!(v));
}
toggle_channel_enabled(val, &ch.name, true);
println!(
" {}",
rsclaw_i18n::t_fmt(
"cli_account_added",
lang,
&[("name", &new_name)],
)
);
changed = true;
}
Err(e) => {
println!(
" [!] {}",
rsclaw_i18n::t_fmt(
"cli_login_failed",
lang,
&[("err", &e.to_string())],
)
);
}
}
} else if edit_fields_at_prefix(val, &ch.fields, &prefix, lang) {
changed = true;
}
} else {
let acct = names[sel].clone();
let edit_label = rsclaw_i18n::t("cli_edit", lang);
let remove_label = rsclaw_i18n::t("cli_account_remove", lang);
let back_label2 = rsclaw_i18n::t("cli_back", lang);
let inner = [
edit_label.as_str(),
remove_label.as_str(),
back_label2.as_str(),
];
let inner_prompt = rsclaw_i18n::t_fmt("cli_account_action", lang, &[("name", &acct)]);
match select_step(&format!(" {inner_prompt}"), &inner, 0) {
StepResult::Next(0) => {
let prefix = format!("channels.{}.accounts.{}", ch.name, acct);
if edit_fields_at_prefix(val, &ch.fields, &prefix, lang) {
changed = true;
}
}
StepResult::Next(1) => {
remove_account(val, &ch.name, &acct);
changed = true;
}
_ => {}
}
}
}
changed
}
async fn configure_channels(val: &mut serde_json::Value, defs: &Defaults) -> Result<()> {
let lang = rsclaw_i18n::default_lang();
header(&rsclaw_i18n::t("cli_section_channels", lang));
hint(&rsclaw_i18n::t("cli_channels_hint", lang));
let term = console::Term::stderr();
let mut cursor: usize = 0;
loop {
let finished_label = rsclaw_i18n::t("cli_finished", lang);
let configured_label = rsclaw_i18n::t("cli_configured", lang);
let mut items: Vec<String> = vec![finished_label];
items.extend(defs.channels.iter().map(|ch| {
let enabled = get_channel_enabled(val, &ch.name);
let configured = channel_is_configured(val, &ch.name);
let check = if enabled {
"\x1b[32m\u{25c9}\x1b[0m"
} else {
"\u{25cb}"
};
let accts = if ch.multi_account {
account_names(val, &ch.name).len()
} else {
0
};
let tag: String = if accts > 0 {
rsclaw_i18n::t_fmt("cli_accounts_count", lang, &[("n", &accts.to_string())])
} else if configured {
configured_label.clone()
} else {
String::new()
};
format!(
"{} {}{}",
check,
ch.label,
if tag.trim().is_empty() {
String::new()
} else {
format!(" ({})", tag.trim())
}
)
}));
let _ = term.clear_screen();
println!(" {}", rsclaw_i18n::t("cli_section_channels", lang));
println!(" {}", "\u{2500}".repeat(20));
println!(" {}", rsclaw_i18n::t("cli_channels_hint_short", lang));
println!();
for (i, item) in items.iter().enumerate() {
if i == cursor {
println!(" \x1b[36m> {item}\x1b[0m");
} else {
println!(" {item}");
}
}
println!();
match term.read_key() {
Ok(console::Key::ArrowUp) => {
if cursor > 0 {
cursor -= 1;
}
}
Ok(console::Key::ArrowDown) => {
if cursor < items.len() - 1 {
cursor += 1;
}
}
Ok(console::Key::Char(' ')) => {
if cursor == 0 {
continue;
} let ch = &defs.channels[cursor - 1];
let is_enabled = get_channel_enabled(val, &ch.name);
toggle_channel_enabled(val, &ch.name, !is_enabled);
}
Ok(console::Key::Enter) => {
if cursor == 0 {
break;
} let _ = term.clear_screen();
let ch_clone = defs.channels[cursor - 1].clone();
edit_channel_config(val, &ch_clone).await;
}
Ok(console::Key::Escape) => break,
Ok(console::Key::Char('q')) => break,
_ => {}
}
}
let _ = term.clear_screen();
Ok(())
}
async fn configure_web_search(val: &mut serde_json::Value) -> Result<()> {
let lang = rsclaw_i18n::default_lang();
header(&rsclaw_i18n::t("cli_section_web_search", lang));
let lang = rsclaw_i18n::default_lang();
let providers: Vec<String> = if lang == "zh" {
vec![
"Bing (免费)".into(),
"Baidu/百度 (免费)".into(),
"Sogou/搜狗 (免费)".into(),
"DuckDuckGo (免费)".into(),
"Serper/Google (需要接口密钥)".into(),
"Google (需要接口密钥)".into(),
"Bing (需要接口密钥)".into(),
"Brave (需要接口密钥)".into(),
]
} else {
vec![
"Bing (free, no key)".into(),
"Baidu (free, no key)".into(),
"Sogou (free, no key)".into(),
"DuckDuckGo (free, no key)".into(),
"Serper/Google (API key)".into(),
"Google (API key)".into(),
"Bing (API key)".into(),
"Brave (API key)".into(),
]
};
let provider_refs: Vec<&str> = providers.iter().map(|s| s.as_str()).collect();
let current = get_nested_value(val, "tools.webSearch.provider")
.and_then(|v| v.as_str().map(|s| s.to_owned()))
.unwrap_or_default();
let default_idx = match current.as_str() {
"bing-free" => 0,
"baidu-free" => 1,
"sogou-free" => 2,
"duckduckgo-free" => 3,
"serper" => 4,
"google" => 5,
"bing" => 6,
"brave" => 7,
_ => 0,
};
let search_prompt = rsclaw_i18n::t("cli_search_provider", lang);
match select_step(&format!(" {search_prompt}"), &provider_refs, default_idx) {
StepResult::Next(0) => {
ensure_json_path(val, &["tools"]);
ensure_json_path(val, &["tools", "webSearch"]);
set_nested_value(
val,
"tools.webSearch.provider",
serde_json::json!("bing-free"),
)?;
}
StepResult::Next(1) => {
ensure_json_path(val, &["tools"]);
ensure_json_path(val, &["tools", "webSearch"]);
set_nested_value(
val,
"tools.webSearch.provider",
serde_json::json!("baidu-free"),
)?;
}
StepResult::Next(2) => {
ensure_json_path(val, &["tools"]);
ensure_json_path(val, &["tools", "webSearch"]);
set_nested_value(
val,
"tools.webSearch.provider",
serde_json::json!("sogou-free"),
)?;
}
StepResult::Next(3) => {
ensure_json_path(val, &["tools"]);
ensure_json_path(val, &["tools", "webSearch"]);
set_nested_value(
val,
"tools.webSearch.provider",
serde_json::json!("duckduckgo-free"),
)?;
}
StepResult::Next(4) => {
ensure_json_path(val, &["tools"]);
ensure_json_path(val, &["tools", "webSearch"]);
set_nested_value(val, "tools.webSearch.provider", serde_json::json!("serper"))?;
match password_step(" Serper API Key") {
StepResult::Next(key) if !key.is_empty() => {
set_nested_value(val, "tools.webSearch.serperApiKey", serde_json::json!(key))?;
}
_ => {}
}
}
StepResult::Next(5) => {
ensure_json_path(val, &["tools"]);
ensure_json_path(val, &["tools", "webSearch"]);
set_nested_value(val, "tools.webSearch.provider", serde_json::json!("google"))?;
match password_step(" Google API Key") {
StepResult::Next(key) if !key.is_empty() => {
set_nested_value(val, "tools.webSearch.googleApiKey", serde_json::json!(key))?;
}
_ => {}
}
match input_step(" Google CX (Custom Search ID)", String::new()) {
StepResult::Next(cx) if !cx.is_empty() => {
set_nested_value(val, "tools.webSearch.googleCx", serde_json::json!(cx))?;
}
_ => {}
}
}
StepResult::Next(6) => {
ensure_json_path(val, &["tools"]);
ensure_json_path(val, &["tools", "webSearch"]);
set_nested_value(val, "tools.webSearch.provider", serde_json::json!("bing"))?;
match password_step(" Bing API Key") {
StepResult::Next(key) if !key.is_empty() => {
set_nested_value(val, "tools.webSearch.bingApiKey", serde_json::json!(key))?;
}
_ => {}
}
}
StepResult::Next(7) => {
ensure_json_path(val, &["tools"]);
ensure_json_path(val, &["tools", "webSearch"]);
set_nested_value(val, "tools.webSearch.provider", serde_json::json!("brave"))?;
match password_step(" Brave API Key") {
StepResult::Next(key) if !key.is_empty() => {
set_nested_value(val, "tools.webSearch.braveApiKey", serde_json::json!(key))?;
}
_ => {}
}
}
_ => {}
}
Ok(())
}
async fn configure_upload_limits(val: &mut serde_json::Value) -> Result<()> {
let lang = rsclaw_i18n::default_lang();
header(&rsclaw_i18n::t("cli_section_upload_limits", lang));
let current_size = get_nested_value(val, "tools.upload.maxFileSize")
.and_then(|v| v.as_u64())
.unwrap_or(50_000_000)
/ 1_000_000;
let current_chars = get_nested_value(val, "tools.upload.maxTextChars")
.and_then(|v| v.as_u64())
.unwrap_or(50_000);
let size_prompt = rsclaw_i18n::t("cli_max_file_size", lang);
match input_step(&format!(" {size_prompt}"), current_size as u32) {
StepResult::Next(mb) => {
ensure_json_path(val, &["tools"]);
ensure_json_path(val, &["tools", "upload"]);
set_nested_value(
val,
"tools.upload.maxFileSize",
serde_json::json!(mb as u64 * 1_000_000),
)?;
}
_ => return Ok(()),
}
let chars_prompt = rsclaw_i18n::t("cli_max_text_chars", lang);
match input_step(&format!(" {chars_prompt}"), current_chars as u32) {
StepResult::Next(chars) => {
ensure_json_path(val, &["tools"]);
ensure_json_path(val, &["tools", "upload"]);
set_nested_value(val, "tools.upload.maxTextChars", serde_json::json!(chars))?;
}
_ => return Ok(()),
}
let current_vision =
get_nested_value(val, "tools.upload.supportsVision").and_then(|v| v.as_bool());
let vision_options = &["Auto-detect", "Yes", "No"];
let default_v = match current_vision {
Some(true) => 1,
Some(false) => 2,
None => 0,
};
let vision_prompt = rsclaw_i18n::t("cli_vision_support", lang);
match select_step(&format!(" {vision_prompt}"), vision_options, default_v) {
StepResult::Next(0) => {
if let Some(obj) = val
.pointer_mut("/tools/upload")
.and_then(|v| v.as_object_mut())
{
obj.remove("supportsVision");
}
}
StepResult::Next(1) => {
ensure_json_path(val, &["tools"]);
ensure_json_path(val, &["tools", "upload"]);
set_nested_value(val, "tools.upload.supportsVision", serde_json::json!(true))?;
}
StepResult::Next(2) => {
ensure_json_path(val, &["tools"]);
ensure_json_path(val, &["tools", "upload"]);
set_nested_value(val, "tools.upload.supportsVision", serde_json::json!(false))?;
}
_ => {}
}
Ok(())
}
async fn configure_exec_safety(val: &mut serde_json::Value) -> Result<()> {
let lang = rsclaw_i18n::default_lang();
header(&rsclaw_i18n::t("cli_section_exec_safety", lang));
let current = get_nested_value(val, "tools.exec.safety")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let status = if current {
rsclaw_i18n::t("cli_exec_enabled", lang)
} else {
rsclaw_i18n::t("cli_exec_disabled", lang)
};
step(
"*",
&rsclaw_i18n::t_fmt("cli_exec_current", lang, &[("status", &status)]),
);
let enable_prompt = rsclaw_i18n::t("cli_enable_exec_safety", lang);
match confirm_step(&format!(" {enable_prompt}"), current) {
StepResult::Next(enabled) => {
ensure_json_path(val, &["tools"]);
ensure_json_path(val, &["tools", "exec"]);
set_nested_value(val, "tools.exec.safety", serde_json::json!(enabled))?;
if enabled {
step("*", &rsclaw_i18n::t("cli_exec_safety_on", lang));
} else {
step("*", &rsclaw_i18n::t("cli_exec_safety_off", lang));
}
}
_ => {}
}
Ok(())
}
async fn run_channel_login(channel: &str) -> anyhow::Result<Vec<(String, String)>> {
let client = reqwest::Client::new();
match channel {
"wechat" | "weixin" => {
let lang = rsclaw_i18n::default_lang();
println!(" {}", rsclaw_i18n::t("cli_scanning_qr", lang));
let (_url, qrcode) =
rsclaw_channel::wechat::WeChatPersonalChannel::start_qr_login(&client).await?;
let (token, bot_id) =
rsclaw_channel::wechat::WeChatPersonalChannel::wait_qr_login(&client, &qrcode)
.await?;
println!(
" {}",
rsclaw_i18n::t_fmt("cli_login_success_bot", lang, &[("id", &bot_id)])
);
Ok(vec![
("botId".to_string(), bot_id),
("botToken".to_string(), token),
])
}
"feishu" | "lark" => {
let brand = if channel == "lark" { "lark" } else { "feishu" };
let (app_id, app_secret, actual_brand) =
rsclaw_channel::auth::feishu_auth::onboard(&client, brand).await?;
println!(
" {}",
rsclaw_i18n::t_fmt(
"cli_login_success_brand",
rsclaw_i18n::default_lang(),
&[("brand", &actual_brand)]
)
);
Ok(vec![
("appId".to_string(), app_id),
("appSecret".to_string(), app_secret),
("brand".to_string(), actual_brand),
("connectionMode".to_string(), "websocket".to_string()),
])
}
_ => {
anyhow::bail!("no login flow implemented for channel '{channel}'");
}
}
}
async fn test_provider_connectivity(
base_url: &str,
api_key: Option<&str>,
provider_name: &str,
api_type: Option<&str>,
model: Option<&str>,
) -> anyhow::Result<()> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
if provider_name == "rsclaw" {
return probe_rsclaw_connectivity(&client, base_url, api_key).await;
}
if provider_name == "gemini" {
return Ok(());
}
let effective_api = api_type
.filter(|s| !s.is_empty())
.unwrap_or_else(|| match provider_name {
"anthropic" => "anthropic",
"doubao" | "bytedance" => "openai-responses",
"ollama" => "ollama",
_ => "openai-completions",
});
let probe_model = model
.map(|m| m.rsplit_once('/').map(|(_, m)| m).unwrap_or(m))
.unwrap_or("test");
let base_trimmed = base_url.trim_end_matches('/');
let (url, body, auth) = match effective_api {
"openai-responses" => {
let base = if base_trimmed.is_empty() {
"https://api.openai.com/v1"
} else {
base_trimmed
};
(
format!("{base}/responses"),
serde_json::json!({
"model": probe_model,
"input": "hi",
"max_output_tokens": 1,
"stream": false,
}),
"bearer",
)
}
"anthropic" | "anthropic-messages" => {
let base = if base_trimmed.is_empty() {
"https://api.anthropic.com/v1"
} else {
base_trimmed
};
let url = if base.ends_with("/v1") || base.contains("/v1/") {
format!("{base}/messages")
} else {
format!("{base}/v1/messages")
};
(
url,
serde_json::json!({
"model": probe_model,
"max_tokens": 1,
"messages": [{"role": "user", "content": "hi"}],
}),
"x-api-key",
)
}
"ollama" => {
let base = if base_trimmed.is_empty() {
"http://localhost:11434"
} else {
base_trimmed
};
let url = format!("{base}/api/tags");
let resp = client
.get(&url)
.send()
.await
.map_err(|e| anyhow::anyhow!("connection failed: {e}"))?;
let status = resp.status();
return if status.is_success() {
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("{status}: {}", rsclaw_util::truncate_str(&body, 200))
};
}
_ => {
let base = if base_trimmed.is_empty() {
"https://api.openai.com/v1"
} else {
base_trimmed
};
(
format!("{base}/chat/completions"),
serde_json::json!({
"model": probe_model,
"max_tokens": 1,
"messages": [{"role": "user", "content": "hi"}],
"stream": false,
}),
"bearer",
)
}
};
let mut req = client.post(&url).json(&body);
if let Some(key) = api_key {
match auth {
"x-api-key" => {
req = req
.header("x-api-key", key)
.header("anthropic-version", "2023-06-01")
.header("authorization", format!("Bearer {key}"));
}
_ => {
req = req.header("authorization", format!("Bearer {key}"));
}
}
}
let resp = req
.send()
.await
.map_err(|e| anyhow::anyhow!("connection failed: {e}"))?;
let status = resp.status();
let code = status.as_u16();
if status.is_success() {
return Ok(());
}
if code == 400 || code == 422 {
return Ok(());
}
let body = resp.text().await.unwrap_or_default();
let snippet = rsclaw_util::truncate_str(&body, 200);
if code == 401 || code == 403 {
anyhow::bail!("{status} (auth): {snippet}");
}
anyhow::bail!("{status}: {snippet}");
}
async fn probe_rsclaw_connectivity(
_client: &reqwest::Client,
base_url: &str,
api_key: Option<&str>,
) -> anyhow::Result<()> {
let base = if base_url.is_empty() {
rsclaw_provider::rsclaw::RSCLAW_DEFAULT_BASE
} else {
base_url
};
let url = format!("{}/models", base.trim_end_matches('/'));
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(10))
.build()?;
let mut current = url.clone();
for _ in 0..5 {
let mut req = client.get(¤t);
if let Some(key) = api_key {
req = req.header("authorization", format!("Bearer {key}"));
}
let resp = req
.send()
.await
.map_err(|e| anyhow::anyhow!("connection failed: {e}"))?;
let status = resp.status();
if status.is_redirection() {
if let Some(loc) = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
{
current = loc.to_owned();
continue;
}
anyhow::bail!("rsclaw probe: {status} without Location header");
}
if status.is_success() {
return Ok(());
}
let body = resp.text().await.unwrap_or_default();
return match status.as_u16() {
401 | 403 => Err(anyhow::anyhow!(
"connected but API key is invalid ({})",
status.as_u16()
)),
_ => Err(anyhow::anyhow!(
"{status}: {}",
rsclaw_util::truncate_str(&body, 200)
)),
};
}
anyhow::bail!("rsclaw probe: redirect loop (>5 hops)")
}
fn resolve_config_path_for_write() -> std::path::PathBuf {
if let Some(existing) = rsclaw_config::loader::detect_config_path() {
return existing;
}
rsclaw_config::loader::base_dir().join("rsclaw.json5")
}
fn rotate_backups(path: &std::path::Path) {
let ext = path.extension().unwrap_or_default().to_string_lossy();
let bak3 = path.with_extension(format!("{ext}.bak.3"));
let bak2 = path.with_extension(format!("{ext}.bak.2"));
let bak1 = path.with_extension(format!("{ext}.bak.1"));
let _ = std::fs::remove_file(&bak3);
let _ = std::fs::rename(&bak2, &bak3);
let _ = std::fs::rename(&bak1, &bak2);
let _ = std::fs::copy(path, &bak1);
}
fn ensure_json_path(val: &mut serde_json::Value, keys: &[&str]) {
let mut cur = val;
for key in keys {
if cur.as_object().is_none_or(|o| !o.contains_key(*key))
&& let Some(obj) = cur.as_object_mut()
{
obj.insert(
(*key).to_owned(),
serde_json::Value::Object(serde_json::Map::new()),
);
}
cur = match cur.as_object_mut().and_then(|o| o.get_mut(*key)) {
Some(v) => v,
None => return,
};
}
}
#[cfg(test)]
mod account_helper_tests {
use serde_json::json;
use super::*;
fn fields() -> Vec<ChannelFieldDef> {
vec![
ChannelFieldDef {
key: "appId".into(),
prompt: "App ID".into(),
secret: false,
placeholder: String::new(),
},
ChannelFieldDef {
key: "appSecret".into(),
prompt: "App Secret".into(),
secret: true,
placeholder: String::new(),
},
]
}
#[test]
fn account_names_empty_when_no_channels() {
let v = json!({});
assert!(account_names(&v, "feishu").is_empty());
}
#[test]
fn onboard_api_type_starts_unset_so_provider_default_wins() {
assert_eq!(initial_onboard_api_type(), "");
}
#[test]
fn account_names_sorted() {
let v = json!({
"channels": { "feishu": { "accounts": {
"zeta": { "appId": "z" },
"alpha": { "appId": "a" },
"mu": { "appId": "m" },
} } }
});
assert_eq!(account_names(&v, "feishu"), vec!["alpha", "mu", "zeta"]);
}
#[test]
fn has_top_level_fields_detects_legacy_layout() {
let v = json!({ "channels": { "feishu": { "appId": "x" } } });
assert!(has_top_level_fields(&v, "feishu", &fields()));
let v2 = json!({ "channels": { "feishu": { "enabled": true } } });
assert!(!has_top_level_fields(&v2, "feishu", &fields()));
}
#[test]
fn migrate_top_to_accounts_moves_fields_under_default() {
let mut v = json!({
"channels": { "feishu": {
"appId": "id1",
"appSecret": "sec1",
"enabled": true,
} }
});
migrate_top_to_accounts(&mut v, "feishu", &fields(), "default");
let feishu = &v["channels"]["feishu"];
assert_eq!(feishu["accounts"]["default"]["appId"], "id1");
assert_eq!(feishu["accounts"]["default"]["appSecret"], "sec1");
assert!(feishu.get("appId").is_none(), "appId should have moved");
assert!(
feishu.get("appSecret").is_none(),
"appSecret should have moved"
);
assert_eq!(feishu["enabled"], true, "enabled should stay top-level");
}
#[test]
fn remove_account_drops_accounts_key_when_last() {
let mut v = json!({
"channels": { "feishu": { "accounts": {
"work": { "appId": "w" }
} } }
});
remove_account(&mut v, "feishu", "work");
assert!(
v["channels"]["feishu"].get("accounts").is_none(),
"accounts key should be dropped when empty"
);
}
#[test]
fn remove_account_keeps_accounts_key_when_others_remain() {
let mut v = json!({
"channels": { "feishu": { "accounts": {
"work": { "appId": "w" },
"personal": { "appId": "p" }
} } }
});
remove_account(&mut v, "feishu", "work");
assert!(v["channels"]["feishu"]["accounts"].get("work").is_none());
assert_eq!(
v["channels"]["feishu"]["accounts"]["personal"]["appId"],
"p"
);
}
#[test]
fn channel_is_configured_recognises_accounts() {
let v = json!({
"channels": { "feishu": { "accounts": { "work": { "appId": "w" } } } }
});
assert!(channel_is_configured(&v, "feishu"));
let v2 = json!({ "channels": { "feishu": { "accounts": {} } } });
assert!(!channel_is_configured(&v2, "feishu"));
let v3 = json!({ "channels": { "feishu": { "enabled": true } } });
assert!(!channel_is_configured(&v3, "feishu"));
}
}