Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/maa-cli/docs/en-US/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ By default, logs go to standard error (stderr). The `--log-file` option can redi

All logs normally include a timestamp and level prefix. The `MAA_LOG_PREFIX` environment variable controls this behavior: `Always` always includes prefixes, `Auto` includes prefixes in log files but not in stderr output, and `Never` omits prefixes even in log files.

### Structured Event Output

For programmatic callers (such as a web UI or scheduler wrapping maa-cli), the global `--emit-json` flag makes every MaaCore callback appear as one JSON object per line on stdout, with the shape `{"kind": "TaskChainStart", "time": "2026-08-07T02:15:51.123+08:00", "message": {…}}` — `kind` is the callback variant name, `time` is when the event was emitted, and `message` is the raw MaaCore payload. MaaCore payloads carry no time of their own, so maa-cli generates `time` at the moment it writes the event, from the same clock the human-readable log uses. It is formatted as RFC 3339 with millisecond precision and the local UTC offset, which parses unambiguously in any consumer. It records an approximate emission time rather than a strict ordering key: callbacks arriving within the same millisecond share a timestamp, and a wall-clock adjustment during a run can make successive timestamps tie or move backwards. The human-readable log still goes to stderr, and side effects such as the end-of-run summary, non-zero exit on task errors, and outbound report requests still fire. The flag is not intended for interactive use. One exception to the stdout split: the interactive `copilot` and `sss-copilot` presets print operator tables and confirmation prompts on stdout while building their task configuration, before any event is emitted, so their stdout is not pure JSONL. Those presets need a human at the keyboard regardless.

In addition to MaaCore callbacks, maa-cli emits a synthetic `TaskRegistered` event once per task before the run starts, in the same JSONL stream. Each event carries `{"id": …, "name": …, "task_type": …, "params": …}` — the task id MaaCore assigned at registration, the task's configured name (or its task type name when unnamed), the task type as it appears in MaaCore (e.g. `"Fight"`, `"Award"`), and the resolved parameter object. The `id` value matches the `taskid` field carried by MaaCore's own `TaskChainStart` and `TaskChainCompleted` callbacks, which lets a consumer tie a running task chain back to the parameters it was registered with. Consumers can use these events to preview the plan, for example to display which stage a `Fight` task will target, before any real MaaCore callback arrives.

### Other Subcommands

Besides the above commands, maa-cli provides additional subcommands:
Expand Down
6 changes: 6 additions & 0 deletions crates/maa-cli/docs/zh-CN/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ maa-cli 默认会向标准误 (stderr) 输出日志。`--log-file` 选项可以

默认情况下,所有输出的日志会包含时间戳和日志级别的前缀。你可以通过环境变量 `MAA_LOG_PREFIX` 来改变这个行为。设置为 `Always` 时,总是会包含前缀,设置为 `Auto` 时输出到日志文件时会包含前缀,而输出到 stderr 时不会包含前缀,而设置为 `Never` 时即使是写入日志文件时也不会包含前缀。

### 结构化事件输出

面向程序化调用方(例如上层的 Web UI 或调度器),maa-cli 提供 `--emit-json` 全局参数。启用后,MaaCore 每次回调都会以一行 JSON 的形式写入标准输出(stdout),每行的结构为 `{"kind": "TaskChainStart", "time": "2026-08-07T02:15:51.123+08:00", "message": {…}}`,其中 `kind` 为回调类型名,`time` 为事件的发出时刻,`message` 为 MaaCore 原始回调载荷。MaaCore 的回调载荷本身不携带时间,因此 `time` 由 maa-cli 在写出该事件的那一刻生成,所用时钟与人类可读日志一致。其格式为 RFC 3339,精确到毫秒,并带有本地时区偏移;该格式在各类调用方中均可无歧义地解析。需要注意的是,该字段记录的是事件的大致发出时刻,而非严格的排序依据:同一毫秒内到达的多条回调会共享同一时间戳,运行期间若系统时钟发生调整,相邻事件的时间戳也可能相同甚至回退。人类可读的日志仍然写入 stderr,任务总结、错误退出码、上报请求等副作用也照常触发。该选项不适合交互式使用,建议仅在被其他程序调用时启用。此外,stdout 与 stderr 的分离存在一处例外:`copilot` 与 `sss-copilot` 两个交互式预设在构建任务配置时会向 stdout 打印干员表格与确认提示,且发生在任何事件写出之前,因此这两个预设的 stdout 并非纯粹的 JSONL;这两个预设本身即需要人工操作。

除 MaaCore 回调之外,maa-cli 会在任务开始运行前,为每个成功注册到 MaaCore 的任务额外向同一 JSONL 流发送一条 `TaskRegistered` 合成事件。每条事件的 `message` 结构为 `{"id": …, "name": …, "task_type": …, "params": …}`:`id` 为注册时 MaaCore 为该任务分配的任务 id,与 MaaCore 自身 `TaskChainStart`、`TaskChainCompleted` 等回调所携带的 `taskid` 字段取值一致,调用方据此可将正在运行的任务链准确关联回其注册参数;`name` 为任务配置中的名称(未指定时回退为任务类型名称),`task_type` 为 MaaCore 使用的任务类型名(如 `"Fight"`、`"Award"`),`params` 为解析后的参数对象。调用方可利用这些事件预览本次运行的完整计划,例如在 `Fight` 任务真正开始前就得知其目标关卡。

### 其他子命令

除了上述的命令外,maa-cli 还提供了其他一些子命令:
Expand Down
18 changes: 18 additions & 0 deletions crates/maa-cli/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ pub(crate) struct Cli {
/// and parameters will be set to default values.
#[arg(long, global = true)]
pub(crate) batch: bool,
/// Emit MaaCore callback events as JSONL on stdout for structured consumption.
///
/// When set, each MaaCore callback becomes one line of JSON on stdout with
/// the shape `{"kind": "TaskChainStart", "time": "<RFC 3339>", "message": {…}}`.
/// MaaCore payloads carry no time of their own, so `time` is generated when the
/// event is written, with millisecond precision and the local UTC offset. The
/// human log still goes to stderr as usual, and the summary / error-flagging
/// side effects of callback processing still run. Intended for programmatic
/// drivers, not for interactive use.
#[arg(long, global = true, verbatim_doc_comment)]
pub(crate) emit_json: bool,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个为什么不做成 run::CommonArgs? Core 只有 run 相关的命令才会加载。

#[command(flatten)]
pub(crate) log: log::Args,
}
Expand Down Expand Up @@ -320,6 +331,13 @@ mod test {
assert!(parse_from(["maa", "list", "--batch"]).batch);
}

#[test]
fn emit_json() {
assert!(!parse_from(["maa", "run", "stage"]).emit_json);
assert!(parse_from(["maa", "--emit-json", "run", "stage"]).emit_json);
assert!(parse_from(["maa", "run", "stage", "--emit-json"]).emit_json);
}

#[cfg(feature = "core_installer")]
#[test]
fn install() {
Expand Down
20 changes: 11 additions & 9 deletions crates/maa-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ fn main() -> Result<()> {
maa_value::userinput::enable_batch_mode()
}

let emit_json = cli.emit_json;

match cli.command {
#[cfg(feature = "core_installer")]
Command::Install { force, common } => {
Expand Down Expand Up @@ -94,15 +96,15 @@ fn main() -> Result<()> {
_ => {}
}
}
Command::Run { task, common } => run::run_custom(task, common)?,
Command::StartUp { params, common } => run::run_preset(params, common)?,
Command::CloseDown { params, common } => run::run_preset(params, common)?,
Command::Fight { params, common } => run::run_preset(params, common)?,
Command::Roguelike { params, common } => run::run_preset(params, common)?,
Command::Copilot { params, common } => run::run_preset(params, common)?,
Command::SSSCopilot { params, common } => run::run_preset(params, common)?,
Command::ParadoxCopilot { params, common } => run::run_preset(params, common)?,
Command::Reclamation { params, common } => run::run_preset(params, common)?,
Command::Run { task, common } => run::run_custom(task, common, emit_json)?,
Command::StartUp { params, common } => run::run_preset(params, common, emit_json)?,
Command::CloseDown { params, common } => run::run_preset(params, common, emit_json)?,
Command::Fight { params, common } => run::run_preset(params, common, emit_json)?,
Command::Roguelike { params, common } => run::run_preset(params, common, emit_json)?,
Command::Copilot { params, common } => run::run_preset(params, common, emit_json)?,
Command::SSSCopilot { params, common } => run::run_preset(params, common, emit_json)?,
Command::ParadoxCopilot { params, common } => run::run_preset(params, common, emit_json)?,
Command::Reclamation { params, common } => run::run_preset(params, common, emit_json)?,
Command::Convert {
input,
output,
Expand Down
214 changes: 211 additions & 3 deletions crates/maa-cli/src/run/callback/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod summary;
use std::{
fmt::Write,
io::Write as _,
sync::{Arc, atomic::AtomicBool},
};

Expand All @@ -18,16 +19,79 @@ fn json_pretty(value: &impl serde::Serialize) -> String {
serde_json::to_string_pretty(value).unwrap_or_else(|e| format!("<serialization error: {e}>"))
}

/// One line of the `--emit-json` stream.
///
/// A struct rather than `serde_json::json!` because the field order below is
/// the emitted key order (a `serde_json::Map` without `preserve_order` sorts
/// its keys), and reading a line as "what happened, when, with what payload"
/// is worth keeping.
#[derive(serde::Serialize)]
struct Event<'a> {
kind: &'a str,
time: String,
message: &'a Value,
}

/// Render an event as a single line of JSON for `--emit-json` mode.
///
/// The shape is `{"kind": "<Kind>", "time": "<RFC 3339>", "message": <payload>}`.
/// The caller supplies the kind as a string so the same helper can emit both
/// MaaCore callbacks (whose `MessageKind` is stringified via its `Debug` repr,
/// e.g. `"TaskChainStart"`, `"Unknown(9999)"`) and synthetic events such as
/// `"TaskRegistered"`. MaaCore payloads carry no time of their own, so `time`
/// is taken from the caller's clock; it is passed in rather than read here so
/// tests can assert on a fixed value. Returns `None` if the event fails to
/// serialize (should not happen in practice — `message` is a `Value` we just
/// built).
pub(crate) fn format_event_line(

@wangl-cc wangl-cc Sep 12, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个额外的序列化有点多余吧,Event 可以直接直接持有 MessageKind 和 time 没必要转成字符串。

MessageKind 当前没提供序列化,可以在 maa-types 里面加一下。

kind: &str,
time: chrono::DateTime<chrono::Local>,
message: &Value,
) -> Option<String> {
let event = Event {
kind,
// RFC 3339 with millisecond precision and a local UTC offset, which
// parses unambiguously in every consumer. It records an approximate
// emission time, not an ordering key: callbacks arriving within the
// same millisecond share a timestamp, and a wall-clock adjustment can
// make successive events tie or move backwards.
time: time.to_rfc3339_opts(chrono::SecondsFormat::Millis, false),
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
message,
};
serde_json::to_string(&event).ok()
}

/// Write one JSONL event to stdout under `--emit-json`, silently dropping the
/// line on write failure (e.g. broken pipe). Locks stdout for the duration.
///
/// The event is timestamped here, at the moment it is emitted, from the same
/// clock the human-readable log uses, so the two streams stay consistent.
pub(crate) fn emit_json_event(kind: &str, message: &Value) {
if let Some(line) = format_event_line(kind, chrono::Local::now(), message) {
let mut out = std::io::stdout().lock();
// Ignore write errors: if stdout is closed (broken pipe), we silently
// drop the line rather than propagate an error up through a MaaCore
// callback thread or the task-registration loop.
let _ = writeln!(out, "{line}");
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
// `Stdout` is only documented to be line-buffered when connected to a
// terminal, so on a pipe the flush is what actually gets the event out
// now rather than whenever the buffer happens to fill.
let _ = out.flush();
}
}

pub struct MaaCallback {
auto_reconnect: bool,
emit_json: bool,
offline_stop_requested: Arc<AtomicBool>,
}

impl MaaCallback {
pub fn new(auto_reconnect: bool) -> (Self, Arc<AtomicBool>) {
pub fn new(auto_reconnect: bool, emit_json: bool) -> (Self, Arc<AtomicBool>) {
let offline_stop_requested = Arc::new(AtomicBool::new(false));
let cb = Self {
auto_reconnect,
emit_json,
offline_stop_requested: Arc::clone(&offline_stop_requested),
};
(cb, offline_stop_requested)
Expand All @@ -44,6 +108,17 @@ impl Callback for MaaCallback {
log::warn!("Failed to parse message for {kind:?}: {message}");
return;
};

// Emit structured event on stdout for programmatic consumers before
// running the human-facing processor. `process_message` is always
// invoked afterwards because it carries side effects a driver still
// needs — `MAA_CORE_ERRORED` for the exit status, the `summary::`
// state for `--no-summary`, and the outbound `ReportRequest` HTTP
// send in `process_report`.
if self.emit_json {
emit_json_event(&format!("{kind:?}"), &message);
}

self.process_message(kind, message);
}
}
Expand Down Expand Up @@ -629,15 +704,148 @@ mod tests {

#[test]
fn offline_confirm_stops_when_auto_reconnect_disabled() {
let (cb, offline_stop) = MaaCallback::new(false);
let (cb, offline_stop) = MaaCallback::new(false, false);
cb.on_message(MessageKind::SubTaskStart, Some(offline_confirm_msg()));
assert!(offline_stop.load(std::sync::atomic::Ordering::Relaxed));
}

#[test]
fn offline_confirm_does_not_stop_when_auto_reconnect_enabled() {
let (cb, offline_stop) = MaaCallback::new(true);
let (cb, offline_stop) = MaaCallback::new(true, false);
cb.on_message(MessageKind::SubTaskStart, Some(offline_confirm_msg()));
assert!(!offline_stop.load(std::sync::atomic::Ordering::Relaxed));
}

/// A fixed instant, so assertions on the emitted `time` field do not depend
/// on when the test runs. It is rendered in the machine's local offset, so
/// assertions compare instants rather than the literal string.
fn fixed_time() -> chrono::DateTime<chrono::Local> {
use chrono::{TimeZone as _, Timelike as _};

chrono::FixedOffset::east_opt(8 * 3600)
.unwrap()
.with_ymd_and_hms(2026, 8, 7, 2, 15, 51)
.unwrap()
.with_nanosecond(123_000_000)
.unwrap()
.into()
}

#[test]
fn format_event_line_known_kind() {
let kind = MessageKind::TaskChainStart;
let message: Value = serde_json::from_str(r#"{"taskchain":"Fight","taskid":42}"#).unwrap();
let line = format_event_line(&format!("{kind:?}"), fixed_time(), &message).unwrap();
let parsed: Value = serde_json::from_str(&line).unwrap();
assert_eq!(
parsed.get("kind").and_then(Value::as_str),
Some("TaskChainStart")
);
assert_eq!(
parsed
.get("message")
.and_then(|m| m.get("taskchain"))
.and_then(Value::as_str),
Some("Fight"),
);
assert_eq!(
parsed
.get("message")
.and_then(|m| m.get("taskid"))
.and_then(Value::as_i64),
Some(42),
);
// The whole event is a single JSON line, no embedded newlines.
assert!(!line.contains('\n'));
}

#[test]
fn format_event_line_carries_timestamp() {
let message: Value = serde_json::from_str(r#"{"taskchain":"Fight"}"#).unwrap();
let line = format_event_line("TaskChainStart", fixed_time(), &message).unwrap();
let parsed: Value = serde_json::from_str(&line).unwrap();

let time = parsed.get("time").and_then(Value::as_str).unwrap();
// RFC 3339 with a UTC offset, so consumers parse it unambiguously.
assert_eq!(
chrono::DateTime::parse_from_rfc3339(time).unwrap(),
fixed_time(),
);
// Millisecond precision, so events within the same second stay ordered.
assert!(time.contains(".123"), "{time} lacks millisecond precision");
}

#[test]
fn format_event_line_envelope_key_order() {
let message: Value = serde_json::from_str("{}").unwrap();
let line = format_event_line("TaskChainStart", fixed_time(), &message).unwrap();

// The envelope reads as "what happened, when, with what payload".
let kind_at = line.find(r#""kind""#).unwrap();
let time_at = line.find(r#""time""#).unwrap();
let message_at = line.find(r#""message""#).unwrap();
assert!(kind_at < time_at && time_at < message_at, "{line}");
}

#[test]
fn format_event_line_unknown_kind() {
let kind = MessageKind::Unknown(9999);
let message: Value = serde_json::from_str("{}").unwrap();
let line = format_event_line(&format!("{kind:?}"), fixed_time(), &message).unwrap();
let parsed: Value = serde_json::from_str(&line).unwrap();
assert_eq!(
parsed.get("kind").and_then(Value::as_str),
Some("Unknown(9999)"),
);
}

#[test]
fn format_event_line_task_registered() {
// Synthetic event emitted by `run_core` for each task registered with
// MaaCore. Shape mirrors the JSON built in `run/mod.rs`.
let message = serde_json::json!({
"id": 42,
"name": "Daily Fight",
"task_type": "Fight",
"params": { "stage": "1-7" },
});
let line = format_event_line("TaskRegistered", fixed_time(), &message).unwrap();
let parsed: Value = serde_json::from_str(&line).unwrap();
assert_eq!(
parsed.get("kind").and_then(Value::as_str),
Some("TaskRegistered"),
);
// The task id MaaCore assigned at registration; consumers match it
// against the `taskid` carried by `TaskChainStart`.
assert_eq!(
parsed
.get("message")
.and_then(|m| m.get("id"))
.and_then(Value::as_i64),
Some(42),
);
assert_eq!(
parsed
.get("message")
.and_then(|m| m.get("name"))
.and_then(Value::as_str),
Some("Daily Fight"),
);
assert_eq!(
parsed
.get("message")
.and_then(|m| m.get("task_type"))
.and_then(Value::as_str),
Some("Fight"),
);
assert_eq!(
parsed
.get("message")
.and_then(|m| m.get("params"))
.and_then(|p| p.get("stage"))
.and_then(Value::as_str),
Some("1-7"),
);
assert!(!line.contains('\n'));
}
}
8 changes: 8 additions & 0 deletions crates/maa-cli/src/run/callback/summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ pub(crate) fn display() -> Option<()> {
with_summary(|summary| print!("{summary}"))
}

/// Write the summary to stderr instead of stdout.
///
/// Used by `--emit-json` mode so the summary does not pollute the JSONL stream
/// on stdout while still remaining visible to a human watching stderr.
pub(crate) fn display_to_stderr() -> Option<()> {
with_summary(|summary| eprint!("{summary}"))
}

pub(super) fn start_task(id: AsstTaskId) -> Option<()> {
with_summary_mut(|summary| summary.start_task(id)).flatten()
}
Expand Down
Loading
Loading