use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::{os::unix::process::CommandExt, process::Command as StdCommand};
use anyhow::{Context, Result, bail};
use beam_core::{
ApiHealth, BeamPaths, BotConfig, CreateSessionRequest, DaemonRuntimeState, FinalOutputRequest,
MentionTarget, RestartSessionRequest, ResumeSessionRequest, Session, SessionInputRequest,
SessionStatus, SessionSummary,
};
use clap::{Args, Parser, Subcommand};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::filter::LevelFilter;
mod ask_hook;
mod autostart;
mod global_config;
mod hook_setup;
mod register_app;
mod workflow_cli;
#[derive(Debug, Parser)]
#[command(name = "beam", version, about = "Rust core runtime for beam")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Start,
Stop,
Restart,
Logs,
Status,
#[command(name = "list", alias = "ls")]
List {
#[arg(long)]
plain: bool,
},
Attach {
session_id: String,
},
Workflow {
#[command(subcommand)]
command: workflow_cli::WorkflowCommand,
},
Send(SendArgs),
History(HistoryArgs),
Quoted(QuotedArgs),
Bots {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
Setup,
Migrate {
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
Dashboard,
Autostart {
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
Schedule {
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
Report {
content: Option<String>,
},
Ask {
content: Option<String>,
},
Hook {
cli_id: Option<String>,
},
Voice {
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
Lang {
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
Session {
#[command(subcommand)]
command: SessionCommand,
},
#[command(hide = true, name = "__daemon")]
InternalDaemon,
#[command(hide = true, name = "__worker")]
InternalWorker(WorkerArgs),
}
#[derive(Debug, Subcommand)]
enum SessionCommand {
Create(SessionCreateArgs),
List,
Attach {
session_id: String,
},
Input(SessionInputArgs),
Refresh {
session_id: String,
},
Restart {
session_id: String,
#[arg(long, default_value = "")]
prompt: String,
},
Resume {
session_id: String,
#[arg(long, default_value = "")]
prompt: String,
},
Adopt(SessionAdoptArgs),
Discover,
Close {
session_id: String,
},
Info {
session_id: String,
},
}
#[derive(Debug, Args)]
struct SessionCreateArgs {
#[arg(long)]
title: String,
#[arg(long)]
cli_id: String,
#[arg(long)]
cli_bin: String,
#[arg(long)]
working_dir: String,
#[arg(long, default_value = "")]
prompt: String,
#[arg(trailing_var_arg = true)]
cli_args: Vec<String>,
}
#[derive(Debug, Args)]
struct SessionAdoptArgs {
#[arg(long)]
target: String,
#[arg(long)]
cli_id: String,
#[arg(long)]
cli_bin: String,
#[arg(long)]
title: Option<String>,
}
#[derive(Debug, Args)]
struct SendArgs {
content: Option<String>,
#[arg(long = "mention", value_name = "OPEN_ID[:NAME]")]
mention: Vec<String>,
#[arg(long = "mention-back")]
mention_back: bool,
#[arg(long = "no-mention")]
no_mention: bool,
#[arg(long = "content-file", value_name = "PATH")]
content_file: Option<PathBuf>,
#[arg(long = "files", visible_alias = "file", value_name = "PATH")]
files: Vec<String>,
#[arg(long = "images", visible_alias = "image", value_name = "PATH")]
images: Vec<String>,
#[arg(long = "top-level")]
top_level: bool,
#[arg(long = "chat-id", value_name = "OC_XXX")]
chat_id: Option<String>,
#[arg(long = "into", value_name = "MESSAGE_ID")]
into: Option<String>,
#[arg(long = "quote", value_name = "MESSAGE_ID")]
quote: Option<String>,
#[arg(long = "no-quote")]
no_quote: bool,
#[arg(long = "card")]
card: bool,
#[arg(long = "text")]
text: bool,
#[arg(long = "anyway")]
anyway: bool,
#[arg(long = "attention", value_name = "KIND", num_args = 0..=1, default_missing_value = "blocked")]
attention: Option<String>,
#[arg(long = "voice")]
voice: bool,
}
fn parse_mention(raw: &str) -> Result<MentionTarget> {
let trimmed = raw.trim();
if trimmed.is_empty() {
bail!("--mention value must not be empty");
}
if let Some((open_id, name)) = trimmed.split_once(':') {
let open_id = open_id.trim();
let name = name.trim();
if open_id.is_empty() {
bail!("--mention open_id must not be empty in \"{}\"", trimmed);
}
Ok(MentionTarget {
open_id: open_id.to_string(),
name: if name.is_empty() {
None
} else {
Some(name.to_string())
},
})
} else {
Ok(MentionTarget {
open_id: trimmed.to_string(),
name: None,
})
}
}
fn build_send_request(args: SendArgs) -> Result<FinalOutputRequest> {
let has_explicit_mentions = !args.mention.is_empty();
let mention_count = [has_explicit_mentions, args.mention_back, args.no_mention]
.iter()
.filter(|&&v| v)
.count();
if mention_count > 1 {
bail!(
"incompatible mention flags: --no-mention cannot be combined with --mention or --mention-back. \
Choose exactly one mention policy."
);
}
if !has_explicit_mentions && !args.mention_back && !args.no_mention {
bail!(
"no mention decision: you must choose exactly one of --mention-back, \
--mention <open_id[:name]>, or --no-mention for every beam send. \
The daemon will refuse messages without an explicit mention policy."
);
}
if let Some(ref kind) = args.attention {
let allowed = ["authz", "decision", "blocked", "help"];
if !allowed.contains(&kind.as_str()) {
bail!(
"invalid attention kind \"{}\": must be one of {}",
kind,
allowed.join("|")
);
}
}
let mentions: Vec<MentionTarget> = args
.mention
.iter()
.map(|raw| parse_mention(raw))
.collect::<Result<Vec<_>>>()?;
let content = read_send_content_v2(args.content, args.content_file.as_deref())?;
if args.attention.is_some() {
if args.top_level {
bail!(
"--attention cannot be combined with --top-level. Attention is for the current session context only."
);
}
if args.chat_id.is_some() {
bail!(
"--attention cannot be combined with --chat-id. Attention is for the current session context only."
);
}
if args.into.is_some() {
bail!(
"--attention cannot be combined with --into. Attention is for the current session context only."
);
}
if args.voice {
bail!(
"--attention cannot be combined with --voice. Attention requires a text/card message."
);
}
if content.trim().is_empty() {
bail!("--attention requires a non-empty text reason in the message body.");
}
}
Ok(FinalOutputRequest {
content,
mentions,
mention_back: args.mention_back,
no_mention: args.no_mention,
files: args.files,
images: args.images,
top_level: args.top_level,
chat_id: args.chat_id,
into: args.into,
quote: args.quote,
no_quote: args.no_quote,
voice: args.voice,
attention: args.attention,
card: args.card,
text: args.text,
anyway: args.anyway,
})
}
fn read_send_content_v2(
content_arg: Option<String>,
content_file: Option<&Path>,
) -> Result<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(ref file_path) = content_file {
let file_content = std::fs::read_to_string(file_path)
.with_context(|| format!("failed to read content file: {}", file_path.display()))?;
let trimmed = file_content.trim_end().to_string();
if !trimmed.is_empty() {
parts.push(trimmed);
}
}
if let Some(content) = content_arg {
let trimmed = content.trim().to_string();
if !trimmed.is_empty() {
parts.push(trimmed);
}
}
if !parts.is_empty() {
return Ok(parts.join("\n"));
}
let mut stdin_body = String::new();
use std::io::Read;
std::io::stdin().read_to_string(&mut stdin_body)?;
let stdin_body = stdin_body.trim_end().to_string();
if stdin_body.is_empty() {
bail!("send content is empty (provide positional arg, --content-file, or stdin)");
}
Ok(stdin_body)
}
#[derive(Debug, Args)]
struct SessionInputArgs {
session_id: String,
content: String,
#[arg(long)]
raw: bool,
}
#[derive(Debug, Args)]
struct HistoryArgs {
#[arg(long, default_value_t = 50)]
limit: usize,
#[arg(long, default_value = "session")]
scope: String,
#[arg(long)]
session_id: Option<String>,
}
#[derive(Debug, Args)]
struct QuotedArgs {
message_id: String,
#[arg(long)]
session_id: Option<String>,
}
#[derive(Debug, Args)]
struct WorkerArgs {
#[arg(long)]
init_path: PathBuf,
}
fn find_runtime(paths: &BeamPaths) -> Result<DaemonRuntimeState> {
let raw = std::fs::read(paths.runtime_state_json()).with_context(|| {
format!(
"daemon state not found at {}",
paths.runtime_state_json().display()
)
})?;
Ok(serde_json::from_slice(&raw)?)
}
async fn api_client(paths: &BeamPaths) -> Result<(Client, String)> {
let runtime = find_runtime(paths)?;
Ok((Client::new(), format!("http://{}", runtime.api_addr)))
}
fn resolve_session_owner_approver(paths: &BeamPaths, session_id: &str) -> Option<String> {
let raw = std::fs::read(paths.session_store_json()).ok()?;
let sessions =
serde_json::from_slice::<std::collections::HashMap<String, Session>>(&raw).ok()?;
sessions
.get(session_id)
.and_then(|session| session.owner_open_id.as_ref())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
async fn post_ask(paths: &BeamPaths, body: &serde_json::Value) -> Result<serde_json::Value> {
let (client, base) = api_client(paths).await?;
let auth = client.get(format!("{}/api/auth", base)).send().await?;
let auth_json: serde_json::Value = auth.json().await?;
let dashboard_token = auth_json
.get("token")
.and_then(|v| v.as_str())
.filter(|value| !value.trim().is_empty())
.map(|value| value.to_string());
tracing::info!(
session_id = body
.get("sessionId")
.and_then(|v| v.as_str())
.unwrap_or_default(),
chat_id = body
.get("chatId")
.and_then(|v| v.as_str())
.unwrap_or_default(),
lark_app_id = body
.get("larkAppId")
.and_then(|v| v.as_str())
.unwrap_or_default(),
approvers = body
.get("approvers")
.and_then(|v| v.as_array())
.map(|arr| arr.len())
.unwrap_or(0),
"beam hook submitting ask request"
);
let mut body = body.clone();
let needs_approver = body
.get("approvers")
.and_then(|value| value.as_array())
.map(|arr| {
arr.iter()
.all(|value| value.as_str().unwrap_or("").trim().is_empty())
})
.unwrap_or(true);
if needs_approver {
if let Some(session_id) = body.get("sessionId").and_then(|value| value.as_str()) {
if let Some(owner_open_id) = resolve_session_owner_approver(paths, session_id) {
if let Some(obj) = body.as_object_mut() {
obj.insert("approvers".to_string(), serde_json::json!([owner_open_id]));
}
}
}
}
let resp = client
.post(format!("{}/api/asks", base))
.header("x-dashboard-token", dashboard_token.unwrap_or_default())
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
tracing::warn!(%status, error = %text, "beam hook ask submission failed");
bail!("{}", text);
}
let result = resp.json().await?;
tracing::info!("beam hook ask request accepted");
Ok(result)
}
fn resolve_ask_context(
paths: &BeamPaths,
payload: &serde_json::Value,
) -> Result<(String, String, String, Option<String>)> {
let payload_cli_session_id = payload
.get("sessionID")
.and_then(|v| v.as_str())
.or_else(|| payload.get("sessionId").and_then(|v| v.as_str()))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let mut chat_id = std::env::var("BEAM_CHAT_ID")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let mut lark_app_id = std::env::var("BEAM_LARK_APP_ID")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let mut root_message_id = std::env::var("BEAM_ROOT_MESSAGE_ID")
.ok()
.and_then(|value| {
let value = value.trim().to_string();
if value.is_empty() { None } else { Some(value) }
});
let mut session_id = None;
if let Ok(raw) = std::fs::read_to_string(paths.session_store_json()) {
if let Ok(sessions) =
serde_json::from_str::<std::collections::HashMap<String, Session>>(&raw)
{
if let Some(cli_session_id) = payload_cli_session_id.as_deref() {
if let Some((beam_session_id, session)) = sessions
.iter()
.find(|(_, session)| session.cli_session_id.as_deref() == Some(cli_session_id))
{
session_id = Some(beam_session_id.clone());
chat_id.get_or_insert_with(|| session.chat_id.clone());
lark_app_id.get_or_insert_with(|| session.lark_app_id.clone());
if root_message_id.is_none() {
let value = session.root_message_id.trim().to_string();
if !value.is_empty() {
root_message_id = Some(value);
}
}
}
}
if session_id.is_none() {
let active_sessions = sessions
.iter()
.filter(|(_, session)| {
session.cli_id.as_deref() == Some("opencode")
&& session.status == SessionStatus::Active
})
.collect::<Vec<_>>();
if active_sessions.len() == 1 {
let (beam_session_id, session) = active_sessions[0];
session_id = Some(beam_session_id.clone());
chat_id.get_or_insert_with(|| session.chat_id.clone());
lark_app_id.get_or_insert_with(|| session.lark_app_id.clone());
if root_message_id.is_none() {
let value = session.root_message_id.trim().to_string();
if !value.is_empty() {
root_message_id = Some(value);
}
}
}
}
}
}
if session_id.is_none() {
session_id = Some(discover_session_id(paths)?);
}
if chat_id.is_none() || lark_app_id.is_none() || root_message_id.is_none() {
if let Ok(raw) = std::fs::read_to_string(paths.session_store_json()) {
if let Ok(sessions) =
serde_json::from_str::<std::collections::HashMap<String, Session>>(&raw)
{
if let Some(session_key) = session_id.as_deref() {
if let Some(session) = sessions.get(session_key) {
chat_id.get_or_insert_with(|| session.chat_id.clone());
lark_app_id.get_or_insert_with(|| session.lark_app_id.clone());
if root_message_id.is_none() {
let value = session.root_message_id.trim().to_string();
if !value.is_empty() {
root_message_id = Some(value);
}
}
}
}
}
}
}
let session_id = session_id.unwrap_or_default();
let chat_id = chat_id
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("unable to resolve chat_id for session {}", session_id))?;
let lark_app_id = lark_app_id
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
anyhow::anyhow!("unable to resolve lark_app_id for session {}", session_id)
})?;
Ok((session_id, chat_id, lark_app_id, root_message_id))
}
fn discover_session_id(paths: &BeamPaths) -> Result<String> {
let env_session_id = std::env::var("BEAM_SESSION_ID")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
discover_session_id_from_pid(paths, std::process::id(), env_session_id.as_deref())
}
fn resolve_cli_session_id(paths: &BeamPaths, explicit: Option<String>) -> Result<String> {
match explicit {
Some(value) if !value.trim().is_empty() => Ok(value.trim().to_string()),
_ => discover_session_id(paths).map_err(|_| {
anyhow::anyhow!(
"无法推断 session-id。请在 beam 会话里的 CLI 中运行,或传 --session-id <id>。"
)
}),
}
}
fn discover_session_id_from_pid(
paths: &BeamPaths,
mut pid: u32,
env_session_id: Option<&str>,
) -> Result<String> {
if let Some(value) = env_session_id {
return Ok(value.to_string());
}
let markers = paths.cli_pid_markers_dir();
loop {
let candidate = markers.join(pid.to_string());
if let Ok(raw) = std::fs::read_to_string(&candidate) {
let session_id = raw.trim().to_string();
if !session_id.is_empty() {
return Ok(session_id);
}
}
let stat_path = format!("/proc/{}/stat", pid);
let stat = match std::fs::read_to_string(stat_path) {
Ok(stat) => stat,
Err(_) => break,
};
let end = match stat.rfind(')') {
Some(end) => end,
None => break,
};
let rest = stat.get(end + 2..).unwrap_or_default();
let mut parts = rest.split_whitespace();
let _state = parts.next();
let ppid = match parts.next().and_then(|value| value.parse::<u32>().ok()) {
Some(ppid) if ppid > 1 && ppid != pid => ppid,
_ => break,
};
pid = ppid;
}
bail!("could not infer session id from BEAM_SESSION_ID or cli pid markers")
}
fn read_send_content(content: Option<String>) -> Result<String> {
if let Some(content) = content {
return Ok(content);
}
let mut body = String::new();
use std::io::Read;
std::io::stdin().read_to_string(&mut body)?;
let body = body.trim_end().to_string();
if body.is_empty() {
bail!("send content is empty");
}
Ok(body)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ScheduleRecord {
#[serde(rename = "scheduleId")]
schedule_id: String,
content: String,
#[serde(rename = "createdAt")]
created_at: String,
status: String,
}
fn read_schedule_records(paths: &BeamPaths) -> Result<Vec<ScheduleRecord>> {
match std::fs::read_to_string(paths.schedules_json()) {
Ok(raw) => Ok(serde_json::from_str(&raw)?),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(err) => Err(err.into()),
}
}
fn write_schedule_records(paths: &BeamPaths, records: &[ScheduleRecord]) -> Result<()> {
if let Some(parent) = paths.schedules_json().parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(
paths.schedules_json(),
serde_json::to_string_pretty(records)? + "\n",
)?;
Ok(())
}
fn cmd_schedule(args: Vec<String>, paths: &BeamPaths) -> Result<()> {
let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
let rest = if args.is_empty() { &[][..] } else { &args[1..] };
let mut records = read_schedule_records(paths)?;
let find_id =
|rest: &[String]| -> Option<String> { rest.iter().find(|s| !s.starts_with('-')).cloned() };
match sub {
"list" | "ls" => {
if records.is_empty() {
println!("暂无定时任务。");
return Ok(());
}
for task in &records {
println!(
"[{}] {} | {} | {}",
task.schedule_id, task.status, task.created_at, task.content
);
}
}
"add" => {
let positional: Vec<String> = rest
.iter()
.filter(|arg| !arg.starts_with("--"))
.cloned()
.collect();
if positional.is_empty() {
anyhow::bail!("Usage: beam schedule add <schedule> <prompt>");
}
let raw_schedule = positional[0].clone();
let prompt = positional
.iter()
.skip(1)
.cloned()
.collect::<Vec<_>>()
.join(" ");
let parsed =
beam_core::parse_schedule(&raw_schedule).map_err(|err| anyhow::anyhow!(err))?;
let content = if prompt.is_empty() {
if let Some(natural) = beam_core::parse_natural_schedule(&positional.join(" ")) {
natural.prompt
} else {
raw_schedule.clone()
}
} else {
prompt
};
let task = ScheduleRecord {
schedule_id: format!(
"sched-{}-{}",
chrono::Utc::now().timestamp_millis(),
std::process::id()
),
content,
created_at: chrono::Utc::now().to_rfc3339(),
status: "active".to_string(),
};
println!("parsed schedule: {}", parsed.display);
println!("{}", serde_json::to_string_pretty(&task)?);
records.push(task);
write_schedule_records(paths, &records)?;
}
"remove" | "rm" | "delete" | "del" => {
let Some(id) = find_id(rest) else {
anyhow::bail!("Usage: beam schedule remove <scheduleId>");
};
let before = records.len();
records.retain(|task| task.schedule_id != id);
if records.len() == before {
anyhow::bail!("未找到任务 {}", id);
}
write_schedule_records(paths, &records)?;
println!("已删除任务 {}", id);
}
"pause" | "disable" => {
let Some(id) = find_id(rest) else {
anyhow::bail!("Usage: beam schedule pause <scheduleId>");
};
let mut found = false;
for task in &mut records {
if task.schedule_id == id {
task.status = "paused".to_string();
found = true;
}
}
if !found {
anyhow::bail!("未找到任务 {}", id);
}
write_schedule_records(paths, &records)?;
println!("已暂停任务 {}", id);
}
"resume" | "enable" => {
let Some(id) = find_id(rest) else {
anyhow::bail!("Usage: beam schedule resume <scheduleId>");
};
let mut found = false;
for task in &mut records {
if task.schedule_id == id {
task.status = "active".to_string();
found = true;
}
}
if !found {
anyhow::bail!("未找到任务 {}", id);
}
write_schedule_records(paths, &records)?;
println!("已恢复任务 {}", id);
}
"run" => {
let Some(id) = find_id(rest) else {
anyhow::bail!("Usage: beam schedule run <scheduleId>");
};
let Some(task) = records.iter().find(|task| task.schedule_id == id) else {
anyhow::bail!("未找到任务 {}", id);
};
println!(
"{{\"scheduleId\":\"{}\",\"content\":\"{}\",\"status\":\"{}\",\"run\":\"now\"}}",
task.schedule_id,
task.content.replace('"', "\\\""),
task.status,
);
}
"logs" => {
let Some(id) = find_id(rest) else {
anyhow::bail!("Usage: beam schedule logs <scheduleId>");
};
let dir = paths.schedules_output_dir().join(&id);
if !dir.exists() {
println!("无日志:{}", dir.display());
} else {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
println!("{}", entry.path().display());
}
}
}
_ => {
anyhow::bail!("未知子命令: {}", sub);
}
}
Ok(())
}
fn current_exe() -> Result<PathBuf> {
Ok(std::env::current_exe().context("failed to locate current executable")?)
}
fn daemon_log_path(paths: &BeamPaths) -> PathBuf {
paths.daemon_log()
}
fn load_bots(paths: &BeamPaths) -> Result<Vec<BotConfig>> {
match std::fs::read_to_string(paths.bots_json()) {
Ok(raw) => Ok(serde_json::from_str(&raw)?),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(err) => Err(err.into()),
}
}
fn load_sessions_from_store(
paths: &BeamPaths,
) -> Result<std::collections::HashMap<String, Session>> {
match std::fs::read(paths.session_store_json()) {
Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
Ok(std::collections::HashMap::new())
}
Err(err) => Err(err.into()),
}
}
#[derive(Debug, Clone, Deserialize)]
struct BotInfoEntry {
#[serde(rename = "larkAppId")]
lark_app_id: String,
#[serde(rename = "botOpenId")]
bot_open_id: Option<String>,
#[serde(rename = "botName")]
bot_name: Option<String>,
#[serde(rename = "cliId")]
cli_id: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
struct BotListEntry {
name: String,
#[serde(rename = "openId")]
open_id: String,
#[serde(rename = "isSelf")]
is_self: bool,
source: &'static str,
#[serde(rename = "larkAppId")]
lark_app_id: String,
#[serde(rename = "workflowBot")]
workflow_bot: String,
capability: Option<String>,
#[serde(rename = "hasTeamRole")]
has_team_role: bool,
mentionable: bool,
#[serde(rename = "mentionSource")]
mention_source: &'static str,
}
fn load_bot_info_entries(paths: &BeamPaths) -> Result<Vec<BotInfoEntry>> {
let path = paths.root().join("bots-info.json");
match std::fs::read_to_string(path) {
Ok(raw) => Ok(serde_json::from_str(&raw)?),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(err) => Err(err.into()),
}
}
fn format_bot_info_entries_for_cli(
entries: &[BotInfoEntry],
current_lark_app_id: &str,
) -> Vec<BotListEntry> {
entries
.iter()
.filter_map(|entry| {
let open_id = entry.bot_open_id.as_ref()?.clone();
let is_self = entry.lark_app_id == current_lark_app_id;
Some(BotListEntry {
name: entry
.bot_name
.clone()
.unwrap_or_else(|| entry.cli_id.clone()),
open_id,
is_self,
source: "configured",
lark_app_id: entry.lark_app_id.clone(),
workflow_bot: entry.lark_app_id.clone(),
capability: None,
has_team_role: false,
mentionable: is_self,
mention_source: if is_self { "self" } else { "fallback" },
})
})
.collect()
}
fn arg_value(args: &[String], name: &str) -> Option<String> {
args.windows(2)
.find_map(|pair| (pair[0] == name).then(|| pair[1].clone()))
}
fn cmd_bots(args: Vec<String>, paths: &BeamPaths) -> Result<()> {
let (sub, rest) = match args.first() {
Some(first) if !first.starts_with('-') => (first.as_str(), &args[1..]),
_ => ("list", args.as_slice()),
};
if sub != "list" && sub != "ls" {
bail!("用法: beam bots list [--session-id ID]");
}
let session_id = match arg_value(rest, "--session-id") {
Some(value) => value,
None => discover_session_id(paths).map_err(|_| {
anyhow::anyhow!(
"无法推断 session-id。请在 Lark 话题内的 CLI 会话中运行,或传 --session-id <id>。"
)
})?,
};
let sessions = load_sessions_from_store(paths)?;
let session = sessions
.get(&session_id)
.with_context(|| format!("未找到 session {}", session_id))?;
if session.lark_app_id.is_empty() {
bail!("session {} 缺少 larkAppId", session_id);
}
let bots =
format_bot_info_entries_for_cli(&load_bot_info_entries(paths)?, &session.lark_app_id);
let out = serde_json::json!({
"sessionId": session_id,
"chatId": session.chat_id,
"bots": bots,
"total": bots.len(),
});
println!("{}", serde_json::to_string_pretty(&out)?);
Ok(())
}
fn daemon_state_is_live(paths: &BeamPaths) -> bool {
let runtime = match find_runtime(paths) {
Ok(runtime) => runtime,
Err(_) => return false,
};
let addr = match runtime.api_addr.parse::<std::net::SocketAddr>() {
Ok(addr) => addr,
Err(_) => return false,
};
std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(300)).is_ok()
}
fn spawn_background_daemon(exe: &Path, paths: &BeamPaths) -> Result<()> {
if paths.runtime_state_json().exists() {
if daemon_state_is_live(paths) {
bail!("daemon appears to be running already");
}
let _ = std::fs::remove_file(paths.runtime_state_json());
}
std::fs::create_dir_all(paths.logs_dir())?;
let log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(daemon_log_path(paths))?;
let mut cmd = StdCommand::new(exe);
let child = unsafe {
cmd.arg("__daemon")
.stdin(Stdio::null())
.stdout(Stdio::from(log.try_clone()?))
.stderr(Stdio::from(log))
.pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
})
.spawn()
}
.context("failed to spawn background daemon")?;
let _ = child.id();
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
BotInfoEntry, Cli, Command, SendArgs, SessionCommand, active_sessions,
bin_candidates_for_cli_id, build_send_request, default_cli_args_for_cli_id,
discover_session_id_from_pid, format_bot_info_entries_for_cli, format_duration,
parse_mention, parse_migrate_flags, resolve_allowed_users, setup_backup_file,
};
use beam_core::{BeamPaths, SessionStatus, SessionSummary};
use chrono::Utc;
use clap::{FromArgMatches, Parser};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn parse_send_args<I, T>(args: I) -> Result<SendArgs, clap::Error>
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
use clap::Args;
let raw: Vec<std::ffi::OsString> = args.into_iter().map(|a| a.into()).collect();
let start = if raw.len() > 1 && raw[1] == "send" {
2
} else {
1
};
let mut input: Vec<std::ffi::OsString> = vec!["_".into()];
if start < raw.len() {
input.extend(raw[start..].iter().cloned());
}
let cmd = clap::Command::new("send");
let cmd = SendArgs::augment_args(cmd);
let matches = cmd.try_get_matches_from(&input)?;
SendArgs::from_arg_matches(&matches).map_err(|e| e.format(&mut clap::Command::new("send")))
}
fn temp_root(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time before unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("beam-cli-{label}-{nanos}-{}", std::process::id()))
}
fn paths_for(root: &Path) -> BeamPaths {
BeamPaths::from_root(root)
}
#[test]
fn clap_accepts_top_level_list_and_bots_default_list_args() {
let cli = Cli::try_parse_from(["beam", "list", "--plain"]).expect("parse list");
assert!(matches!(cli.command, Command::List { plain: true }));
let cli = Cli::try_parse_from(["beam", "ls"]).expect("parse ls alias");
assert!(matches!(cli.command, Command::List { plain: false }));
let cli = Cli::try_parse_from(["beam", "attach", "abc123"]).expect("parse attach");
assert!(matches!(
cli.command,
Command::Attach { session_id } if session_id == "abc123"
));
let cli = Cli::try_parse_from(["beam", "session", "attach", "abc123"])
.expect("parse session attach");
assert!(matches!(
cli.command,
Command::Session { command: SessionCommand::Attach { session_id } } if session_id == "abc123"
));
let cli = Cli::try_parse_from(["beam", "bots", "--session-id", "sid-1"])
.expect("parse bots default list");
match cli.command {
Command::Bots { args } => assert_eq!(args, ["--session-id", "sid-1"]),
other => panic!("unexpected command: {other:?}"),
}
let cli = Cli::try_parse_from(["beam", "bots", "list", "--session-id", "sid-1"])
.expect("parse bots list");
match cli.command {
Command::Bots { args } => assert_eq!(args, ["list", "--session-id", "sid-1"]),
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn parse_migrate_flags_accepts_dry_run_and_force() {
let flags = parse_migrate_flags(&["--dry-run".to_string(), "--force".to_string()])
.expect("parse migrate flags");
assert!(flags.dry_run);
assert!(flags.force);
}
#[test]
fn setup_backup_file_writes_bak_copy() {
let root = temp_root("backup");
let file = root.join("bots.json");
fs::create_dir_all(&root).unwrap();
fs::write(&file, "[]\n").unwrap();
let backup = setup_backup_file(&file)
.expect("backup file")
.expect("backup path");
assert!(backup.ends_with("bots.json.bak"));
assert_eq!(fs::read_to_string(&backup).unwrap(), "[]\n");
let _ = fs::remove_dir_all(root);
}
#[test]
fn setup_defaults_traex_cli_args() {
assert_eq!(default_cli_args_for_cli_id("traex"), vec!["-y".to_string()]);
assert!(default_cli_args_for_cli_id("codex").is_empty());
}
#[test]
fn discover_session_id_prefers_explicit_env_value() {
let root = temp_root("env");
let paths = paths_for(&root);
let found = discover_session_id_from_pid(&paths, 1234, Some("session-from-env"))
.expect("discover from env");
assert_eq!(found, "session-from-env");
let _ = fs::remove_dir_all(root);
}
#[test]
fn discover_session_id_reads_current_pid_marker() {
let root = temp_root("marker");
let paths = paths_for(&root);
fs::create_dir_all(paths.cli_pid_markers_dir()).expect("create marker dir");
let pid = std::process::id();
fs::write(
paths.cli_pid_markers_dir().join(pid.to_string()),
"session-from-marker\n",
)
.expect("write marker");
let found = discover_session_id_from_pid(&paths, pid, None).expect("discover from marker");
assert_eq!(found, "session-from-marker");
let _ = fs::remove_dir_all(root);
}
#[test]
fn discover_session_id_errors_without_env_or_marker() {
let root = temp_root("missing");
let paths = paths_for(&root);
let err = discover_session_id_from_pid(&paths, std::process::id(), None)
.expect_err("missing marker should fail");
assert!(
err.to_string()
.contains("could not infer session id from BEAM_SESSION_ID or cli pid markers")
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn format_bot_info_entries_matches_cli_fallback_shape() {
let entries = vec![
BotInfoEntry {
lark_app_id: "cli_self".to_string(),
bot_open_id: Some("ou_self".to_string()),
bot_name: Some("Self Bot".to_string()),
cli_id: "claude".to_string(),
},
BotInfoEntry {
lark_app_id: "cli_peer".to_string(),
bot_open_id: Some("ou_peer".to_string()),
bot_name: None,
cli_id: "codex".to_string(),
},
BotInfoEntry {
lark_app_id: "cli_missing_open_id".to_string(),
bot_open_id: None,
bot_name: Some("Hidden".to_string()),
cli_id: "gemini".to_string(),
},
];
let out = format_bot_info_entries_for_cli(&entries, "cli_self");
assert_eq!(out.len(), 2);
assert_eq!(out[0].name, "Self Bot");
assert_eq!(out[0].open_id, "ou_self");
assert!(out[0].is_self);
assert!(out[0].mentionable);
assert_eq!(out[0].mention_source, "self");
assert_eq!(out[1].name, "codex");
assert!(!out[1].is_self);
assert!(!out[1].mentionable);
assert_eq!(out[1].mention_source, "fallback");
}
fn make_summary(id: &str, status: SessionStatus, hours_ago: i64) -> SessionSummary {
let ts = Utc::now() - chrono::Duration::hours(hours_ago);
SessionSummary {
session_id: id.to_string(),
title: format!("session {}", id),
status,
chat_type: None,
quote_target_id: None,
cli_id: Some("test-cli".to_string()),
cli_bin: Some("test-bin".to_string()),
cli_args: vec![],
working_dir: Some("/home/user/project".to_string()),
worker_pid: Some(12345),
terminal_url: None,
read_only_token: None,
write_token: None,
created_at: ts,
stream_card_nonce: None,
current_screen: None,
last_screen_status: None,
usage_limit: None,
current_image_key: None,
tui_prompt_card_id: None,
tui_prompt_options: vec![],
tui_prompt_multi_select: None,
tui_toggled_indices: vec![],
pending_response_card_id: None,
pending_response_card_state: None,
last_patched_response_card_id: None,
last_final_output_turn_id: None,
last_final_output: None,
adopted_from: None,
agent_attention: None,
}
}
#[test]
fn active_sessions_filters_out_closed() {
let items = vec![
make_summary("active-1", SessionStatus::Active, 1),
make_summary("closed-1", SessionStatus::Closed, 2),
make_summary("active-2", SessionStatus::Active, 3),
];
let active = active_sessions(&items);
assert_eq!(active.len(), 2);
assert!(active.iter().all(|s| s.status == SessionStatus::Active));
}
#[test]
fn active_sessions_sorts_newest_first() {
let items = vec![
make_summary("old", SessionStatus::Active, 10),
make_summary("new", SessionStatus::Active, 1),
make_summary("mid", SessionStatus::Active, 5),
];
let active = active_sessions(&items);
assert_eq!(active[0].session_id, "new");
assert_eq!(active[1].session_id, "mid");
assert_eq!(active[2].session_id, "old");
}
#[test]
fn active_sessions_returns_empty_when_none_active() {
let items = vec![
make_summary("closed-1", SessionStatus::Closed, 1),
make_summary("closed-2", SessionStatus::Closed, 2),
];
let active = active_sessions(&items);
assert!(active.is_empty());
}
#[test]
fn format_duration_outputs_human_readable() {
assert_eq!(format_duration(0), "0s");
assert_eq!(format_duration(30_000), "30s");
assert_eq!(format_duration(120_000), "2m");
assert_eq!(format_duration(3_600_000), "1h0m");
assert_eq!(format_duration(3_660_000), "1h1m");
assert_eq!(format_duration(86_400_000), "1d0h");
assert_eq!(format_duration(90_000_000), "1d1h");
}
#[test]
fn bin_candidates_for_cli_id_returns_candidates() {
assert_eq!(
bin_candidates_for_cli_id("opencode"),
Some(&["opencode-cli", "opencode"][..])
);
assert_eq!(bin_candidates_for_cli_id("codex"), Some(&["codex"][..]));
assert_eq!(
bin_candidates_for_cli_id("claude-code"),
Some(&["claude"][..])
);
assert_eq!(bin_candidates_for_cli_id("antigravity"), Some(&["agy"][..]));
}
#[test]
fn bin_candidates_for_cli_id_returns_none_for_unknown_id() {
assert_eq!(bin_candidates_for_cli_id("unknown-cli"), None);
}
#[test]
fn resolve_allowed_users_empty_input_no_owner_returns_empty() {
let result = resolve_allowed_users("", None);
assert!(result.is_empty());
}
#[test]
fn resolve_allowed_users_empty_input_with_owner_inserts_owner() {
let result = resolve_allowed_users("", Some("ou_owner"));
assert_eq!(result, vec!["ou_owner"]);
}
#[test]
fn resolve_allowed_users_parses_comma_separated_and_inserts_owner() {
let result = resolve_allowed_users("ou_peer, ou_colleague", Some("ou_owner"));
assert_eq!(result.len(), 3);
assert!(result.contains(&"ou_owner".to_string()));
assert!(result.contains(&"ou_peer".to_string()));
assert!(result.contains(&"ou_colleague".to_string()));
}
#[test]
fn resolve_allowed_users_does_not_duplicate_owner() {
let result = resolve_allowed_users("ou_owner, ou_peer", Some("ou_owner"));
assert_eq!(result.len(), 2);
assert!(result.contains(&"ou_owner".to_string()));
assert!(result.contains(&"ou_peer".to_string()));
}
#[test]
fn resolve_allowed_users_parses_without_owner_open_id() {
let result = resolve_allowed_users("ou_peer, ou_colleague", None);
assert_eq!(result, vec!["ou_peer", "ou_colleague"]);
}
#[test]
fn resolve_allowed_users_trims_whitespace_and_filters_empty() {
let result = resolve_allowed_users(" ou_a , , ou_b , ", None);
assert_eq!(result, vec!["ou_a", "ou_b"]);
}
#[test]
fn send_parse_mention_back_basic() {
let args = parse_send_args(["beam", "send", "--mention-back", "hello world"]);
let args = args.expect("parse send");
assert!(args.mention_back);
assert!(!args.no_mention);
assert!(args.mention.is_empty());
}
#[test]
fn send_parse_no_mention() {
let args = parse_send_args(["beam", "send", "--no-mention", "quiet log"]);
let args = args.expect("parse no-mention");
assert!(args.no_mention);
assert!(!args.mention_back);
}
#[test]
fn send_parse_multiple_mentions() {
let args = parse_send_args([
"beam",
"send",
"--mention",
"ou_abc:Alice",
"--mention",
"ou_def",
"hey folks",
]);
let args = args.expect("parse multi mention");
assert_eq!(args.mention.len(), 2);
assert_eq!(args.mention[0], "ou_abc:Alice");
assert_eq!(args.mention[1], "ou_def");
}
#[test]
fn send_parse_content_file() {
let args = parse_send_args([
"beam",
"send",
"--mention-back",
"--content-file",
"/tmp/msg.txt",
]);
let args = args.expect("parse content-file");
assert!(args.content_file.is_some());
assert_eq!(
args.content_file.as_ref().unwrap().to_str().unwrap(),
"/tmp/msg.txt"
);
}
#[test]
fn send_parse_files_and_images() {
let args = parse_send_args([
"beam",
"send",
"--mention-back",
"hello",
"--files",
"a.pdf",
"--files",
"b.txt",
"--images",
"img1.png",
"--image",
"img2.jpg",
]);
let args = args.expect("parse files/images");
assert_eq!(args.files.len(), 2);
assert_eq!(args.images.len(), 2);
}
#[test]
fn send_parse_file_alias() {
let args = parse_send_args([
"beam",
"send",
"--mention-back",
"hello",
"--file",
"single.pdf",
]);
let args = args.expect("parse --file alias");
assert_eq!(args.files.len(), 1);
assert_eq!(args.files[0], "single.pdf");
}
#[test]
fn send_parse_targeting_flags() {
let args = parse_send_args([
"beam",
"send",
"--no-mention",
"--top-level",
"--chat-id",
"oc_test123",
"--into",
"om_thread1",
"--quote",
"om_ref1",
"--no-quote",
"text",
]);
let args = args.expect("parse targeting");
assert!(args.top_level);
assert_eq!(args.chat_id.as_deref(), Some("oc_test123"));
assert_eq!(args.into.as_deref(), Some("om_thread1"));
assert_eq!(args.quote.as_deref(), Some("om_ref1"));
assert!(args.no_quote);
}
#[test]
fn send_parse_attention_with_kind() {
let args = parse_send_args([
"beam",
"send",
"--mention-back",
"hello",
"--attention=decision",
]);
let args = args.expect("parse attention=decision");
assert_eq!(args.attention.as_deref(), Some("decision"));
}
#[test]
fn send_parse_attention_default() {
let args = parse_send_args(["beam", "send", "--mention-back", "hello", "--attention"]);
let args = args.expect("parse attention default");
assert_eq!(args.attention.as_deref(), Some("blocked"));
}
#[test]
fn send_parse_voice_flag() {
let args = parse_send_args(["beam", "send", "--no-mention", "test", "--voice"]);
let args = args.expect("parse voice");
assert!(args.voice);
}
#[test]
fn send_parse_anyway_flag() {
let args = parse_send_args(["beam", "send", "--no-mention", "test", "--anyway"]);
let args = args.expect("parse anyway");
assert!(args.anyway);
}
#[test]
fn send_parse_card_text_noop() {
let args = parse_send_args([
"beam",
"send",
"--mention-back",
"hello",
"--card",
"--text",
]);
let args = args.expect("parse card/text");
assert!(args.card);
assert!(args.text);
}
#[test]
fn send_build_rejects_no_mention_decision() {
let args = parse_send_args(["beam", "send", "hello"]).expect("parse");
let err = build_send_request(args).expect_err("no mention should fail");
assert!(
err.to_string().contains("no mention decision")
|| err.to_string().contains("must choose")
);
}
#[test]
fn send_build_rejects_invalid_attention_kind() {
let args = parse_send_args([
"beam",
"send",
"--mention-back",
"hello",
"--attention=invalid_kind",
])
.expect("parse");
let err = build_send_request(args).expect_err("invalid attention should fail");
assert!(err.to_string().contains("invalid attention kind"));
}
#[test]
fn send_build_rejects_attention_with_top_level() {
let args = parse_send_args([
"beam",
"send",
"--mention-back",
"hello",
"--attention",
"--top-level",
])
.expect("parse");
let err = build_send_request(args).expect_err("attention + top-level should fail");
assert!(
err.to_string()
.contains("--attention cannot be combined with --top-level")
);
}
#[test]
fn send_build_rejects_attention_with_chat_id() {
let args = parse_send_args([
"beam",
"send",
"--mention-back",
"hello",
"--attention",
"--chat-id",
"oc_test123",
])
.expect("parse");
let err = build_send_request(args).expect_err("attention + chat-id should fail");
assert!(
err.to_string()
.contains("--attention cannot be combined with --chat-id")
);
}
#[test]
fn send_build_rejects_attention_with_into() {
let args = parse_send_args([
"beam",
"send",
"--mention-back",
"hello",
"--attention",
"--into",
"om_test123",
])
.expect("parse");
let err = build_send_request(args).expect_err("attention + into should fail");
assert!(
err.to_string()
.contains("--attention cannot be combined with --into")
);
}
#[test]
fn send_build_rejects_attention_with_voice() {
let args = parse_send_args([
"beam",
"send",
"--mention-back",
"hello",
"--attention",
"--voice",
])
.expect("parse");
let err = build_send_request(args).expect_err("attention + voice should fail");
assert!(
err.to_string()
.contains("--attention cannot be combined with --voice")
);
}
#[test]
fn send_build_passes_voice_to_daemon() {
let args =
parse_send_args(["beam", "send", "--mention-back", "hello", "--voice"]).expect("parse");
let req =
build_send_request(args).expect("voice should not fail at CLI; daemon rejects it");
assert!(req.voice, "voice flag must be passed to daemon");
assert!(req.mention_back);
assert_eq!(req.content, "hello");
}
#[test]
fn send_build_rejects_no_mention_with_mention() {
let args = parse_send_args([
"beam",
"send",
"--no-mention",
"--mention",
"ou_abc:Alice",
"hello",
])
.expect("parse");
let err = build_send_request(args).expect_err("conflict should fail");
assert!(
err.to_string().contains("incompatible")
|| err.to_string().contains("cannot be combined")
);
}
#[test]
fn send_build_rejects_no_mention_with_mention_back() {
let args = parse_send_args(["beam", "send", "--no-mention", "--mention-back", "hello"])
.expect("parse");
let err = build_send_request(args).expect_err("conflict should fail");
assert!(
err.to_string().contains("incompatible")
|| err.to_string().contains("cannot be combined")
);
}
#[test]
fn send_parse_mention_valid_formats() {
let t1 = parse_mention("ou_123:Alice").expect("name parse");
assert_eq!(t1.open_id, "ou_123");
assert_eq!(t1.name.as_deref(), Some("Alice"));
let t2 = parse_mention("ou_456").expect("bare parse");
assert_eq!(t2.open_id, "ou_456");
assert_eq!(t2.name, None);
}
#[test]
fn send_parse_mention_rejects_empty() {
assert!(parse_mention("").is_err());
assert!(parse_mention(":Name").is_err());
}
}
async fn wait_for_health(paths: &BeamPaths) -> Result<ApiHealth> {
let client = Client::new();
for _ in 0..40 {
if let Ok(runtime) = find_runtime(paths) {
let url = format!("http://{}/health", runtime.api_addr);
if let Ok(resp) = client.get(&url).send().await {
if resp.status().is_success() {
return Ok(resp.json::<ApiHealth>().await?);
}
}
}
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
}
bail!("daemon did not become healthy in time")
}
fn format_duration(ms: i64) -> String {
let seconds = ms / 1000;
if seconds < 60 {
return format!("{}s", seconds);
}
let minutes = seconds / 60;
if minutes < 60 {
return format!("{}m", minutes);
}
let hours = minutes / 60;
if hours < 24 {
return format!("{}h{}m", hours, minutes % 60);
}
let days = hours / 24;
format!("{}d{}h", days, hours % 24)
}
fn active_sessions(items: &[SessionSummary]) -> Vec<SessionSummary> {
let mut v: Vec<SessionSummary> = items
.iter()
.filter(|s| s.status == SessionStatus::Active)
.cloned()
.collect();
v.sort_by(|a, b| b.created_at.cmp(&a.created_at));
v
}
fn truncate(s: &str, max: usize) -> &str {
if s.chars().count() <= max {
s
} else {
&s[..s.floor_char_boundary(max)]
}
}
fn shorten_home(path: &str) -> String {
if let Ok(home) = std::env::var("HOME") {
if path.starts_with(&home) {
return path.replacen(&home, "~", 1);
}
}
path.to_string()
}
fn print_sessions(items: &[SessionSummary]) {
let active = active_sessions(items);
if active.is_empty() {
println!("没有活跃会话。");
return;
}
let id_w = 10usize;
let title_w = 28usize;
let dir_w = 28usize;
let pid_w = 8usize;
let uptime_w = 8usize;
let status_w = 7usize;
let hdr = format!(
"{:id_w$} │ {:title_w$} │ {:dir_w$} │ {:pid_w$} │ {:>uptime_w$} │ {:status_w$}",
"id", "title", "working dir", "pid", "uptime", "status",
);
let sep = "─".repeat(hdr.chars().count());
println!("{}", sep);
println!("{}", hdr);
println!("{}", sep);
let now_ms = chrono::Utc::now().timestamp_millis();
for item in &active {
let id = truncate(&item.session_id, id_w);
let title = truncate(&item.title, title_w);
let work_dir = shorten_home(item.working_dir.as_deref().unwrap_or("-"));
let dir = truncate(&work_dir, dir_w);
let pid = item
.worker_pid
.map(|p| p.to_string())
.unwrap_or_else(|| "-".to_string());
let uptime_ms = now_ms - item.created_at.timestamp_millis();
let uptime = format_duration(uptime_ms.max(0));
let status = match item.status {
SessionStatus::Active => "active",
SessionStatus::Closed => "closed",
};
println!(
"{:id_w$} │ {:title_w$} │ {:dir_w$} │ {:>pid_w$} │ {:>uptime_w$} │ {:>status_w$}",
id, title, dir, pid, uptime, status
);
}
println!("{}", sep);
println!("共 {} 个活跃会话", active.len());
}
async fn fetch_sessions(client: &Client, base: &str) -> Result<Vec<SessionSummary>> {
let resp = client.get(format!("{}/sessions", base)).send().await?;
if !resp.status().is_success() {
bail!("{}", resp.text().await.unwrap_or_default());
}
Ok(resp.json::<Vec<SessionSummary>>().await?)
}
fn session_attach_target(session: &SessionSummary) -> String {
let fallback = format!(
"beam-{}",
&session.session_id[..8.min(session.session_id.len())]
);
session
.adopted_from
.as_ref()
.and_then(|adopted| adopted.zellij_session.clone())
.unwrap_or(fallback)
}
fn resolve_session_prefix(items: &[SessionSummary], prefix: &str) -> Result<SessionSummary> {
let matches = items
.iter()
.filter(|session| session.session_id.starts_with(prefix))
.cloned()
.collect::<Vec<_>>();
match matches.len() {
0 => bail!("未找到匹配 \"{}\" 的活跃会话", prefix),
1 => Ok(matches[0].clone()),
_ => {
eprintln!(
"\"{}\" 匹配了 {} 个会话,请提供更长的 ID 前缀:",
prefix,
matches.len()
);
for session in matches {
eprintln!(" {} {}", truncate(&session.session_id, 12), session.title);
}
bail!("session id 前缀不唯一")
}
}
}
fn attach_session(session: &SessionSummary) -> Result<()> {
let target = session_attach_target(session);
let status = StdCommand::new("zellij")
.args(["attach", &target])
.status()
.context("failed to run zellij attach")?;
if !status.success() {
bail!("zellij attach failed for {}", target);
}
Ok(())
}
async fn cmd_attach(client: &Client, base: &str, session_id: &str) -> Result<()> {
let items = fetch_sessions(client, base).await?;
let session = resolve_session_prefix(&items, session_id)?;
attach_session(&session)
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy(),
)
.with_writer(std::io::stderr)
.with_target(false)
.compact()
.init();
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let msg = info.to_string();
if msg.contains("JoinHandle polled after completion") {
tracing::warn!("JoinHandle dropped after task completion (known tokio 1.52 issue)");
return;
}
default_hook(info);
}));
let cli = Cli::parse();
match cli.command {
Command::InternalDaemon => {
let exe = current_exe()?;
beam_daemon::run(
BeamPaths::discover()?,
beam_daemon::RunOptions { worker_exe: exe },
)
.await?;
}
Command::InternalWorker(args) => {
beam_worker::run_from_init_path(&args.init_path).await?;
}
other => {
let paths = BeamPaths::discover()?;
match other {
Command::Start => {
let exe = current_exe()?;
spawn_background_daemon(&exe, &paths)?;
let health = wait_for_health(&paths).await?;
println!("daemon pid={} started_at={}", health.pid, health.started_at);
}
Command::Stop => {
let (client, base) = api_client(&paths).await?;
client.post(format!("{}/shutdown", base)).send().await?;
println!("shutdown requested");
}
Command::Restart => {
if paths.runtime_state_json().exists() {
let (client, base) = api_client(&paths).await?;
let _ = client.post(format!("{}/shutdown", base)).send().await?;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
}
let exe = current_exe()?;
spawn_background_daemon(&exe, &paths)?;
let health = wait_for_health(&paths).await?;
println!("daemon pid={} started_at={}", health.pid, health.started_at);
}
Command::Logs => {
let log = daemon_log_path(&paths);
let content = std::fs::read_to_string(&log).unwrap_or_default();
print!("{}", content);
}
Command::Status => {
let health = wait_for_health(&paths).await?;
println!("daemon pid={} started_at={}", health.pid, health.started_at);
}
Command::List { plain: _plain } => {
let (client, base) = api_client(&paths).await?;
let items = fetch_sessions(&client, &base).await?;
print_sessions(&items);
}
Command::Attach { session_id } => {
let (client, base) = api_client(&paths).await?;
cmd_attach(&client, &base, &session_id).await?;
}
Command::Setup => {
cmd_setup(&paths).await?;
}
Command::Migrate { args } => {
cmd_migrate(&paths, args).await?;
}
Command::Autostart { args } => {
cmd_autostart(&paths, args)?;
}
Command::Dashboard => {
let runtime = find_runtime(&paths)?;
let base = format!("http://{}", runtime.api_addr);
let auth = reqwest::Client::new()
.get(format!("{}/api/auth", base))
.send()
.await?;
let auth_json: serde_json::Value = auth.json().await?;
let url = format!(
"{}{}",
base,
auth_json
.get("loginPath")
.and_then(|v| v.as_str())
.unwrap_or("/dashboard/")
);
println!("Opening dashboard: {}", url);
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
}
Command::Schedule { args } => {
cmd_schedule(args, &paths)?;
}
Command::Report { content } => {
let body = read_send_content(content)?;
let session_id = discover_session_id(&paths)?;
let (client, base) = api_client(&paths).await?;
let resp = client
.post(format!("{}/sessions/{}/report", base, session_id))
.json(&serde_json::json!({ "content": body }))
.send()
.await?;
if !resp.status().is_success() {
bail!("{}", resp.text().await.unwrap_or_default());
}
let out: serde_json::Value = resp.json().await?;
println!("{}", serde_json::to_string_pretty(&out)?);
}
Command::Ask { content } => {
let body = read_send_content(content)?;
let session_id = discover_session_id(&paths)?;
let (client, base) = api_client(&paths).await?;
let resp = client
.post(format!("{}/sessions/{}/input", base, session_id))
.json(&SessionInputRequest {
content: body,
raw: false,
})
.send()
.await?;
if !resp.status().is_success() {
bail!("{}", resp.text().await.unwrap_or_default());
}
println!("ask sent to session {}", session_id);
}
Command::Hook { cli_id } => {
cmd_hook(cli_id, &paths).await?;
}
Command::Voice { args } => cmd_voice(args)?,
Command::Lang { args } => cmd_lang(args)?,
Command::Workflow { command } => {
workflow_cli::handle(command, &paths).await?;
}
Command::Send(args) => {
let req = build_send_request(args)?;
let session_id = discover_session_id(&paths)?;
let (client, base) = api_client(&paths).await?;
let resp = client
.post(format!("{}/sessions/{}/final-output", base, session_id))
.json(&req)
.send()
.await?;
if !resp.status().is_success() {
bail!("{}", resp.text().await.unwrap_or_default());
}
println!("final output accepted");
}
Command::History(args) => {
let session_id = resolve_cli_session_id(&paths, args.session_id)?;
let (client, base) = api_client(&paths).await?;
let resp = client
.get(format!("{}/sessions/{}/history", base, session_id))
.query(&[
("limit", args.limit.to_string()),
("scope", args.scope.clone()),
])
.send()
.await?;
if !resp.status().is_success() {
bail!("{}", resp.text().await.unwrap_or_default());
}
let out: serde_json::Value = resp.json().await?;
println!("{}", serde_json::to_string_pretty(&out)?);
}
Command::Quoted(args) => {
let session_id = resolve_cli_session_id(&paths, args.session_id)?;
let (client, base) = api_client(&paths).await?;
let resp = client
.get(format!(
"{}/sessions/{}/quoted/{}",
base, session_id, args.message_id
))
.send()
.await?;
if !resp.status().is_success() {
bail!("{}", resp.text().await.unwrap_or_default());
}
let out: serde_json::Value = resp.json().await?;
println!("{}", serde_json::to_string_pretty(&out)?);
}
Command::Bots { args } => cmd_bots(args, &paths)?,
Command::Session { command } => {
let (client, base) = api_client(&paths).await?;
match command {
SessionCommand::Create(args) => {
let resp = client
.post(format!("{}/sessions", base))
.json(&CreateSessionRequest {
title: args.title,
cli_id: args.cli_id,
cli_bin: args.cli_bin,
cli_args: args.cli_args,
working_dir: args.working_dir,
prompt: args.prompt,
})
.send()
.await?;
if !resp.status().is_success() {
bail!("{}", resp.text().await.unwrap_or_default());
}
let session = resp.json::<SessionSummary>().await?;
println!("{}", serde_json::to_string_pretty(&session)?);
}
SessionCommand::List => {
let items = fetch_sessions(&client, &base).await?;
print_sessions(&items);
}
SessionCommand::Attach { session_id } => {
cmd_attach(&client, &base, &session_id).await?;
}
SessionCommand::Input(args) => {
let resp = client
.post(format!("{}/sessions/{}/input", base, args.session_id))
.json(&SessionInputRequest {
content: args.content,
raw: args.raw,
})
.send()
.await?;
if !resp.status().is_success() {
bail!("{}", resp.text().await.unwrap_or_default());
}
println!("input accepted");
}
SessionCommand::Refresh { session_id } => {
let resp = client
.post(format!("{}/sessions/{}/refresh", base, session_id))
.send()
.await?;
if !resp.status().is_success() {
bail!("{}", resp.text().await.unwrap_or_default());
}
println!("refresh requested");
}
SessionCommand::Restart { session_id, prompt } => {
let resp = client
.post(format!("{}/sessions/{}/restart", base, session_id))
.json(&RestartSessionRequest { prompt })
.send()
.await?;
if !resp.status().is_success() {
bail!("{}", resp.text().await.unwrap_or_default());
}
println!("restart requested");
}
SessionCommand::Resume { session_id, prompt } => {
let resp = client
.post(format!("{}/sessions/{}/resume", base, session_id))
.json(&ResumeSessionRequest { prompt })
.send()
.await?;
if !resp.status().is_success() {
bail!("{}", resp.text().await.unwrap_or_default());
}
let session = resp.json::<SessionSummary>().await?;
println!("{}", serde_json::to_string_pretty(&session)?);
}
SessionCommand::Adopt(args) => {
let (zellij_session, zellij_pane_id) = match args.target.split_once(':')
{
Some((session, pane)) => (session.to_string(), pane.to_string()),
None => (args.target.clone(), "terminal_0".to_string()),
};
let resp = client
.post(format!("{}/adopt/zellij", base))
.json(&serde_json::json!({
"zellij_session": zellij_session,
"zellij_pane_id": zellij_pane_id,
"cli_id": args.cli_id,
"cli_bin": args.cli_bin,
"title": args.title,
"cwd": "",
}))
.send()
.await?;
if !resp.status().is_success() {
bail!("{}", resp.text().await.unwrap_or_default());
}
let session = resp.json::<SessionSummary>().await?;
println!("{}", serde_json::to_string_pretty(&session)?);
}
SessionCommand::Discover => {
let resp = client.get(format!("{}/adopt/zellij", base)).send().await?;
if !resp.status().is_success() {
bail!("{}", resp.text().await.unwrap_or_default());
}
let items = resp.json::<Vec<serde_json::Value>>().await?;
for item in &items {
let session = item
.get("zellij_session")
.and_then(|v| v.as_str())
.unwrap_or("-");
let pane_id = item
.get("zellij_pane_id")
.and_then(|v| v.as_str())
.unwrap_or("-");
let pid =
item.get("cli_pid").and_then(|v| v.as_i64()).unwrap_or(-1);
let cwd = item.get("cwd").and_then(|v| v.as_str()).unwrap_or("-");
let title =
item.get("title").and_then(|v| v.as_str()).unwrap_or("-");
println!(
"{}:{} pid={} cwd={} {}",
session, pane_id, pid, cwd, title
);
}
}
SessionCommand::Close { session_id } => {
let resp = client
.post(format!("{}/sessions/{}/close", base, session_id))
.send()
.await?;
if !resp.status().is_success() {
bail!("{}", resp.text().await.unwrap_or_default());
}
println!("session closed");
}
SessionCommand::Info { session_id } => {
let resp = client
.get(format!("{}/sessions/{}", base, session_id))
.send()
.await?;
if !resp.status().is_success() {
bail!("{}", resp.text().await.unwrap_or_default());
}
let session = resp.json::<SessionSummary>().await?;
println!("{}", serde_json::to_string_pretty(&session)?);
}
}
}
Command::InternalDaemon | Command::InternalWorker(_) => unreachable!(),
}
}
}
Ok(())
}
fn setup_backup_file(path: &Path) -> Result<Option<PathBuf>> {
if !path.exists() {
return Ok(None);
}
let backup = path.with_extension(format!(
"{}.bak",
path.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("bak")
));
std::fs::copy(path, &backup)?;
Ok(Some(backup))
}
async fn validate_setup_credentials(app_id: &str, app_secret: &str) -> Result<()> {
if std::env::var("BEAM_SKIP_SETUP_VALIDATION").ok().as_deref() == Some("1") {
println!("⚠️ 已跳过远程凭证校验(BEAM_SKIP_SETUP_VALIDATION=1)。");
return Ok(());
}
let client = reqwest::Client::new();
let resp = client
.post("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal")
.json(&serde_json::json!({
"app_id": app_id,
"app_secret": app_secret,
}))
.send()
.await
.context("failed to reach Feishu/Lark credential endpoint")?;
let status = resp.status();
let body: serde_json::Value = resp.json().await.unwrap_or_else(|_| serde_json::json!({}));
if !status.is_success() {
bail!("凭证校验失败: HTTP {}", status);
}
match body.get("code").and_then(|v| v.as_i64()).unwrap_or(-1) {
0 => Ok(()),
code => bail!(
"凭证校验失败: code={} msg={}",
code,
body.get("msg").and_then(|v| v.as_str()).unwrap_or("")
),
}
}
const CLI_CHOICES: &[(&str, &str, &[&str])] = &[
("claude-code", "Claude", &["claude"]),
("codex", "Codex", &["codex"]),
("traex", "Traex", &["traex"]),
("coco", "CoCo", &["coco"]),
("gemini", "Gemini", &["gemini"]),
("opencode", "OpenCode", &["opencode-cli", "opencode"]),
("hermes", "Hermes", &["hermes"]),
("antigravity", "Antigravity", &["agy"]),
];
fn default_cli_args_for_cli_id(cli_id: &str) -> Vec<String> {
match cli_id {
"traex" => vec!["-y".to_string()],
_ => Vec::new(),
}
}
fn detect_installed_clis() -> Vec<&'static (&'static str, &'static str, &'static [&'static str])> {
CLI_CHOICES
.iter()
.filter(|(_, _, bins)| bins.iter().any(|b| which_exists(b)))
.collect()
}
fn bin_candidates_for_cli_id(cli_id: &str) -> Option<&'static [&'static str]> {
CLI_CHOICES
.iter()
.find(|(id, _, _)| *id == cli_id)
.map(|(_, _, bins)| *bins)
}
fn probe_cli_bin(cli_id: &str) -> Option<String> {
bin_candidates_for_cli_id(cli_id)
.and_then(|bins| bins.iter().find(|b| which_exists(b)).copied())
.map(String::from)
}
fn resolve_allowed_users(input: &str, user_open_id: Option<&str>) -> Vec<String> {
let mut allowed: Vec<String> = input
.split(',')
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
.collect();
if let Some(open_id) = user_open_id {
if !allowed.iter().any(|item| item == open_id) {
allowed.push(open_id.to_string());
}
}
allowed
}
fn which_exists(bin: &str) -> bool {
std::process::Command::new("which")
.arg(bin)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn prompt_cli_id() -> Result<String> {
let installed = detect_installed_clis();
if installed.is_empty() {
println!("未检测到已安装的 CLI 工具。");
let value = ask_line("请手动输入 CLI ID [claude-code]: ")?;
let value = value.trim();
if value.is_empty() {
return Ok("claude-code".to_string());
}
let valid_ids: Vec<&str> = CLI_CHOICES.iter().map(|c| c.0).collect();
if valid_ids.contains(&value) {
return Ok(value.to_string());
}
println!(
"不支持的 CLI ID \"{}\",支持: {}",
value,
valid_ids.join(", ")
);
return Ok("claude-code".to_string());
}
println!("已检测到以下 CLI 工具:");
for (i, (_id, label, bins)) in installed.iter().enumerate() {
let bin_str = bins.join(" / ");
println!(" {}) {} ({})", i + 1, label, bin_str);
}
let value = ask_line("CLI 适配器 [1]: ")?;
let value = value.trim();
if value.is_empty() {
return Ok(installed[0].0.to_string());
}
if let Ok(num) = value.parse::<usize>() {
if num >= 1 && num <= installed.len() {
return Ok(installed[num - 1].0.to_string());
}
println!("无效序号 \"{}\",请输入 1-{}", num, installed.len());
} else {
let valid_ids: Vec<&str> = CLI_CHOICES.iter().map(|c| c.0).collect();
if valid_ids.contains(&value) {
return Ok(value.to_string());
}
println!(
"不支持的 CLI ID \"{}\",支持: {}",
value,
valid_ids.join(", ")
);
}
Ok(installed[0].0.to_string())
}
async fn prompt_setup_bot() -> Result<BotConfig> {
let name = ask_line("机器人名称(留空=不设): ")?;
let credentials = register_app::prompt_credentials().await?;
let cli_id = prompt_cli_id()?;
let cli_args = default_cli_args_for_cli_id(&cli_id);
let cli_bin = probe_cli_bin(&cli_id).filter(|bin| bin != &cli_id);
let working_dir = {
let value = ask_line("默认工作目录 [~]: ")?;
if value.trim().is_empty() {
Some("~".to_string())
} else {
Some(value)
}
};
let skip_working_dir_prompt = {
let value = ask_line("是否跳过工作目录选择?[y/N]: ")?;
matches!(value.trim().to_lowercase().as_str(), "y" | "yes")
};
let allowed_users = {
let hint = if credentials.user_open_id.is_some() {
"允许用户 open_id(逗号分隔,留空=仅限自己,你的 open_id 已自动加入): "
} else {
"允许用户 open_id(逗号分隔,例如 ou_xxxxx;留空=不限制,任何人都可对话⚠️): "
};
let value = ask_line(hint)?;
let resolved = resolve_allowed_users(&value, credentials.user_open_id.as_deref());
if credentials.user_open_id.is_none() && resolved.is_empty() {
println!(" ⚠️ 未设置允许用户:当前为开放模式,任何人都可以和机器人对话。");
println!(
" 💡 可在 bots.json 中手动填写 allowedUsers 字段(open_id 以 ou_ 开头),或后续用 /grant 命令授权。"
);
}
resolved
};
Ok(BotConfig {
name: if name.trim().is_empty() {
None
} else {
Some(name)
},
lark_app_id: credentials.app_id,
lark_app_secret: credentials.app_secret,
cli_id,
cli_bin,
cli_args,
model: None,
working_dir,
skip_working_dir_prompt,
lark_encrypt_key: None,
lark_verification_token: None,
allowed_users,
private_card: false,
allowed_chat_groups: Vec::new(),
chat_grants: std::collections::HashMap::new(),
global_grants: Vec::new(),
oncall_chats: Vec::new(),
restrict_grant_commands: false,
message_quota: None,
quota_state: std::collections::HashMap::new(),
})
}
async fn cmd_setup(paths: &BeamPaths) -> Result<()> {
let root = paths.root();
std::fs::create_dir_all(root)?;
for dir in [
paths.logs_dir(),
paths.run_dir(),
paths.sessions_dir(),
paths.workflows_dir(),
paths.workflow_runs_dir(),
paths.state_dir(),
paths.cli_pid_markers_dir(),
paths.observed_bots_dir(),
paths.schedules_output_dir(),
] {
std::fs::create_dir_all(&dir)?;
}
let cfg = paths.config_toml();
if !cfg.exists() {
let defaults = "\
[daemon]\nworking_dirs = [\"~\"]\n\n\
[web]\nhost = \"0.0.0.0\"\nproxy_base_port = 8800\n\n\
[lark]\nevent_mode = \"http\"\n";
std::fs::write(&cfg, defaults)?;
println!("Wrote {}", cfg.display());
} else {
println!("Config exists: {}", cfg.display());
}
let bots = paths.bots_json();
if !bots.exists() {
std::fs::write(&bots, "[]\n")?;
println!("Wrote {}", bots.display());
} else {
println!("Bots config exists: {}", bots.display());
}
println!("Setup complete. Data root: {}", root.display());
println!("现有 bots 数量: {}", load_bots(paths)?.len());
println!();
let existing = load_bots(paths)?;
let action = if existing.is_empty() {
"replace".to_string()
} else {
println!("已检测到现有机器人配置:");
for (i, bot) in existing.iter().enumerate() {
println!(
" {}. {} ({})",
i + 1,
bot.name.clone().unwrap_or_else(|| bot.cli_id.clone()),
bot.lark_app_id
);
}
ask_line("操作 [replace/add/skip] [replace]: ")?
};
let action = action.trim().to_lowercase();
let action = if action.is_empty() {
"replace".to_string()
} else {
action
};
if action == "skip" {
println!("已跳过 setup 写盘。");
return Ok(());
}
let mut next_bots = if action == "add" {
existing.clone()
} else {
Vec::new()
};
let next_bot = prompt_setup_bot().await?;
validate_setup_credentials(&next_bot.lark_app_id, &next_bot.lark_app_secret).await?;
next_bots.push(next_bot);
if bots.exists() {
if let Some(backup) = setup_backup_file(&bots)? {
println!("旧配置已备份: {}", backup.display());
}
}
std::fs::write(&bots, serde_json::to_string_pretty(&next_bots)? + "\n")?;
println!("✅ 已写入 {}", bots.display());
if let Err(err) = hook_setup::install_hooks() {
eprintln!("hook install skipped: {}", err);
} else {
println!("Installed hook config for Claude/OpenCode.");
}
println!("提示:先运行 `beam start`,再用 `beam autostart enable` 注册自启。");
Ok(())
}
fn print_lang_status() {
let cfg = global_config::read_global_config();
let global_lang = cfg.lang.map(|loc| loc.as_str().to_string());
let effective = global_lang.clone().unwrap_or_else(|| "zh".to_string());
println!(
"Global lang: {}",
global_lang.as_deref().unwrap_or("(unset, defaults to zh)")
);
println!("Effective for CLI: {}", effective);
println!(
"Config file: {}",
global_config::global_config_path().display()
);
}
fn cmd_lang(args: Vec<String>) -> Result<()> {
let sub = args.first().map(|s| s.as_str()).unwrap_or("");
if sub.is_empty() {
print_lang_status();
return Ok(());
}
if sub == "--unset" {
global_config::set_global_locale(None)?;
println!("✅ Cleared global lang (will default to zh).");
println!("Run `beam restart` for changes to take effect.");
return Ok(());
}
match sub {
"zh" | "en" => {
let locale = beam_core::i18n::Locale::from_str(sub);
global_config::set_global_locale(Some(locale))?;
println!("✅ Set global lang → {}.", sub);
println!("Run `beam restart` for changes to take effect.");
Ok(())
}
_ => {
eprintln!("Unknown locale \"{}\". Supported: zh, en.", sub);
eprintln!("Usage: beam lang [zh|en|--unset]");
std::process::exit(1);
}
}
}
fn mask_secret(s: Option<&str>) -> String {
match s {
Some(value) if !value.is_empty() => {
let prefix: String = value.chars().take(4).collect();
format!("{}***", prefix)
}
_ => "(未设)".to_string(),
}
}
fn ask_line(prompt: &str) -> Result<String> {
use std::io::{self, Write};
let mut stdout = io::stdout();
stdout.write_all(prompt.as_bytes())?;
stdout.flush()?;
let mut line = String::new();
io::stdin().read_line(&mut line)?;
Ok(line.trim().to_string())
}
fn cmd_voice(args: Vec<String>) -> Result<()> {
let sub = args.first().map(|s| s.as_str()).unwrap_or("");
if sub == "status" {
let cfg = global_config::read_global_config().voice;
if let Some(v) = cfg {
println!("当前语音配置(全局 ~/.beam/config.json):");
println!(
" 引擎: {}",
match v.engine {
Some(global_config::VoiceEngine::Openai) => "openai",
_ => "sami",
}
);
println!(" 音色: {}", v.speaker.as_deref().unwrap_or("(默认)"));
if let Some(rate) = v.rate {
println!(" 语速: {}", rate);
}
if let Some(sami) = v.sami {
println!(
" SAMI: accessKey={} secretKey={} appkey={}{}{}",
mask_secret(sami.access_key.as_deref()),
mask_secret(sami.secret_key.as_deref()),
sami.appkey.as_deref().unwrap_or("(未设)"),
sami.token_url
.as_deref()
.map(|v| format!(" tokenUrl={}", v))
.unwrap_or_default(),
sami.ws_url
.as_deref()
.map(|v| format!(" wsUrl={}", v))
.unwrap_or_default(),
);
}
if let Some(openai) = v.openai {
println!(
" OpenAI: baseUrl={} model={} apiKey={}",
openai.base_url.as_deref().unwrap_or("(未设)"),
openai.model.as_deref().unwrap_or("(未设)"),
mask_secret(openai.api_key.as_deref())
);
}
} else {
println!("语音功能未配置。运行 `beam voice` 配置。");
}
return Ok(());
}
if sub == "disable" || sub == "off" {
global_config::set_global_voice(None)?;
println!(
"✅ 已移除全局语音配置(回复卡片不再显示「🔊 语音总结」按钮)。重启 daemon 生效。"
);
return Ok(());
}
if !sub.is_empty() && sub != "setup" {
eprintln!("用法: beam voice [status|disable](无参 = 交互式配置)");
std::process::exit(1);
}
println!("🔊 配置语音总结(高级功能)。写入全局 ~/.beam/config.json,重启后生效。\n");
let engine = ask_line(
"选择 TTS 引擎 [1] SAMI(需 AK/SK/appkey) [2] OpenAI 兼容(自带 baseUrl/key): ",
)?;
let mut voice = global_config::VoiceConfig::default();
if engine == "2" || engine.to_lowercase().contains("openai") {
voice.engine = Some(global_config::VoiceEngine::Openai);
let base_url = ask_line(
"baseUrl(如 https://api.openai.com/v1,自托管如 http://127.0.0.1:8880/v1): ",
)?;
let api_key = ask_line("apiKey(无则留空): ")?;
let model = ask_line("model(如 tts-1 / kokoro): ")?;
if base_url.is_empty() || model.is_empty() {
eprintln!("❌ baseUrl 和 model 必填,未写入。");
return Ok(());
}
voice.openai = Some(global_config::VoiceOpenAIConfig {
base_url: Some(base_url),
api_key: if api_key.is_empty() {
None
} else {
Some(api_key)
},
model: Some(model),
});
let sp = ask_line("音色 voice(留空=默认 alloy): ")?;
if !sp.is_empty() {
voice.speaker = Some(sp);
}
} else {
voice.engine = Some(global_config::VoiceEngine::Sami);
let access_key = ask_line("SAMI accessKey: ")?;
let secret_key = ask_line("SAMI secretKey: ")?;
let appkey = ask_line("SAMI appkey: ")?;
if access_key.is_empty() || secret_key.is_empty() || appkey.is_empty() {
eprintln!("❌ accessKey/secretKey/appkey 都必填,未写入。");
return Ok(());
}
let mut sami = global_config::VoiceSamiCreds {
access_key: Some(access_key),
secret_key: Some(secret_key),
appkey: Some(appkey),
token_url: None,
ws_url: None,
};
let sp = ask_line("音色 speaker(留空=默认灿灿): ")?;
if !sp.is_empty() {
voice.speaker = Some(sp);
}
let adv = ask_line("自定义 SAMI 端点?一般不用,回车跳过 (y/N): ")?;
if adv.to_lowercase() == "y" {
let token_url = ask_line("tokenUrl(留空用默认): ")?;
let ws_url = ask_line("wsUrl(留空用默认): ")?;
if !token_url.is_empty() {
sami.token_url = Some(token_url);
}
if !ws_url.is_empty() {
sami.ws_url = Some(ws_url);
}
}
voice.sami = Some(sami);
}
let rate = ask_line("语速倍率(留空=1.1): ")?;
if !rate.is_empty() {
if let Ok(parsed) = rate.parse::<f64>() {
voice.rate = Some(parsed);
}
}
global_config::set_global_voice(Some(voice))?;
println!(
"\n✅ 已写入 voice 配置。`beam restart` 后,配了语音的机器人回复卡片底部会出现「🔊 语音总结」按钮。"
);
println!(" 查看:`beam voice status` 关闭:`beam voice disable`");
Ok(())
}
async fn cmd_hook(cli_id: Option<String>, _paths: &BeamPaths) -> Result<()> {
if cli_id.as_deref().unwrap_or("").is_empty() {
eprintln!("Usage: beam hook <cliId>");
std::process::exit(1);
}
use std::io::Read;
let mut sink = String::new();
let _ = std::io::stdin().read_to_string(&mut sink);
let payload: serde_json::Value = match serde_json::from_str(&sink) {
Ok(value) => value,
Err(_) => return Ok(()),
};
let Some(cli_id) = cli_id else {
return Ok(());
};
let Some(parsed) = ask_hook::parse_questions(&cli_id, &payload) else {
return Ok(());
};
let (session_id, chat_id, lark_app_id, root_message_id) =
match resolve_ask_context(_paths, &payload) {
Ok(value) => value,
Err(err) => {
tracing::warn!(error = %err, "beam hook ask context not resolved");
return Ok(());
}
};
let body = serde_json::json!({
"sessionId": session_id,
"chatId": chat_id,
"larkAppId": lark_app_id,
"rootMessageId": root_message_id,
"questions": parsed.questions.iter().map(|q| serde_json::json!({
"prompt": q.prompt,
"options": q.options,
"multiSelect": q.multi_select,
})).collect::<Vec<_>>(),
"timeoutMs": 3_600_000u64,
"approvers": [],
});
let result = match post_ask(_paths, &body).await {
Ok(value) => value,
Err(_) => return Ok(()),
};
let ask_result: beam_core::AskResult = match serde_json::from_value(result) {
Ok(value) => value,
Err(_) => return Ok(()),
};
match ask_result {
beam_core::AskResult::Answered { answers, .. } => {
let directive = ask_hook::format_answer(&cli_id, &answers, &parsed)?;
if !directive.is_empty() {
println!("{directive}");
}
}
_ => {
let directive = ask_hook::passthrough(&cli_id, &payload)?;
if !directive.is_empty() {
println!("{directive}");
}
}
}
Ok(())
}
#[derive(Debug, Default)]
struct MigrateFlags {
dry_run: bool,
force: bool,
}
fn parse_migrate_flags(args: &[String]) -> Result<MigrateFlags> {
let mut flags = MigrateFlags::default();
for arg in args {
match arg.as_str() {
"--dry-run" => flags.dry_run = true,
"--force" | "-f" => flags.force = true,
"--backup" => {}
other => bail!("未知 migrate 参数: {}", other),
}
}
Ok(flags)
}
async fn cmd_migrate(paths: &BeamPaths, args: Vec<String>) -> Result<()> {
let flags = parse_migrate_flags(&args)?;
let home = std::env::var("HOME").context("HOME env var not set")?;
let ts_root = std::path::PathBuf::from(home).join(".beam");
let ts_bots = ts_root.join("bots.json");
if !ts_bots.exists() {
println!("No TS bots.json found at {}", ts_bots.display());
println!("Nothing to migrate.");
return Ok(());
}
let raw = std::fs::read_to_string(&ts_bots)
.with_context(|| format!("failed to read {}", ts_bots.display()))?;
let bots: serde_json::Value = serde_json::from_str(&raw)
.with_context(|| format!("invalid JSON in {}", ts_bots.display()))?;
let rs_bots = paths.bots_json();
let mut conflict_report = Vec::new();
let existing_bots = if rs_bots.exists() {
let existing = std::fs::read_to_string(&rs_bots)?;
if existing.trim().len() > 2 {
conflict_report.push(format!("bots: {}", rs_bots.display()));
Some(existing)
} else {
None
}
} else {
None
};
let ts_sessions = ts_root.join("sessions.json");
let sessions: Vec<serde_json::Value> = if ts_sessions.exists() {
let raw = std::fs::read_to_string(&ts_sessions)
.with_context(|| format!("failed to read {}", ts_sessions.display()))?;
serde_json::from_str(&raw)
.with_context(|| format!("invalid JSON in {}", ts_sessions.display()))?
} else {
Vec::new()
};
let session_plan: Vec<(String, std::path::PathBuf, serde_json::Value)> = sessions
.iter()
.map(|session| {
let session_id = session
.get("sessionId")
.or_else(|| session.get("session_id"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let out_path = paths.sessions_dir().join(format!("{}.json", session_id));
(session_id, out_path, session.clone())
})
.collect();
let session_conflicts: Vec<String> = session_plan
.iter()
.filter(|(_, path, _)| path.exists())
.map(|(id, path, _)| format!("session {} -> {}", id, path.display()))
.collect();
conflict_report.extend(session_conflicts.iter().cloned());
if !conflict_report.is_empty() {
println!("迁移冲突报告:");
for item in &conflict_report {
println!(" - {}", item);
}
if !flags.force {
println!("使用 --force 可覆盖这些冲突目标;使用 --dry-run 仅查看报告。");
if flags.dry_run {
return Ok(());
}
bail!("迁移目标已有内容,已停止。");
}
}
if flags.dry_run {
println!("Dry run:");
println!(
" TS bots: {}",
bots.as_array().map(|a| a.len()).unwrap_or(0)
);
println!(" TS sessions: {}", session_plan.len());
println!(" Rust bots target: {}", rs_bots.display());
for (_, path, _) in &session_plan {
println!(" session target: {}", path.display());
}
return Ok(());
}
std::fs::create_dir_all(paths.root())?;
if existing_bots.is_some() {
let backup = rs_bots.with_extension("json.bak");
std::fs::copy(&rs_bots, &backup)?;
println!("旧 bots 备份: {}", backup.display());
}
std::fs::write(&rs_bots, serde_json::to_string_pretty(&bots)?)?;
println!("Migrated {} -> {}", ts_bots.display(), rs_bots.display());
println!(
"{} bot(s) migrated.",
bots.as_array().map(|a| a.len()).unwrap_or(0)
);
if !session_plan.is_empty() {
std::fs::create_dir_all(paths.sessions_dir())?;
let mut migrated = 0usize;
for (session_id, out_path, session) in session_plan {
if out_path.exists() {
let backup = out_path.with_extension("json.bak");
std::fs::copy(&out_path, &backup)?;
println!("旧 session 备份: {}", backup.display());
}
let migrated_session = serde_json::json!({
"session_id": session_id,
"title": session.get("title").unwrap_or(&serde_json::json!("migrated")),
"chat_id": session.get("chatId").or_else(|| session.get("chat_id")).unwrap_or(&serde_json::json!("")),
"root_message_id": session.get("rootMessageId").or_else(|| session.get("root_message_id")).unwrap_or(&serde_json::json!("")),
"scope": session.get("scope").unwrap_or(&serde_json::json!("thread")),
"status": "closed",
"created_at": chrono::Utc::now().to_rfc3339(),
"lark_app_id": session.get("larkAppId").or_else(|| session.get("lark_app_id")).unwrap_or(&serde_json::json!("unknown")),
"owner_open_id": session.get("ownerOpenId").or_else(|| session.get("owner_open_id")),
"cli_id": session.get("cliId").or_else(|| session.get("cli_id")),
"cli_bin": session.get("cliBin").or_else(|| session.get("cli_bin")),
"working_dir": session.get("workingDir").or_else(|| session.get("working_dir")),
});
tokio::fs::write(
&out_path,
serde_json::to_string_pretty(&migrated_session)? + "\n",
)
.await?;
migrated += 1;
}
println!("Migrated {} session(s).", migrated);
}
Ok(())
}
fn cmd_autostart(paths: &BeamPaths, args: Vec<String>) -> Result<()> {
let action = autostart::parse_action(&args);
let opts = autostart::AutostartOpts {
exe: std::env::current_exe()?,
paths: paths.clone(),
};
match action {
autostart::AutostartAction::Enable => autostart::enable_autostart(&opts),
autostart::AutostartAction::Disable => autostart::disable_autostart(),
autostart::AutostartAction::Status => autostart::autostart_status(),
autostart::AutostartAction::Refresh => {
if autostart::refresh_autostart(&opts)? {
println!("✅ autostart 已刷新");
} else {
println!("ℹ️ autostart 无需更新");
}
Ok(())
}
}
}