Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/cli.rs
T
kdletters 485ed50b26
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
AGC 七项交付效率优化并升级捆绑 Codex 到 0.155.1 (#439)
## 背景

净月潭案例(2026-09-19 21:53 → 09-20 01:26,3 小时 33 分)的问题不是模型慢,而是环境未就绪、验收靠模型自述、预算只约束单个工具入口、工具调用被 SDK 全局串行闸门卡住。本 PR 落地确认后的七项交付效率合同,并把捆绑 Codex 升到当前 npm latest。

## 结果

- 新建 Web 游戏在正式生成前由宿主自动预检:捆绑 Node/npm、真实 Vite 构建、受限浏览器桌面/移动截图;失败不启动生成或付费素材。
- 首个副作用前冻结交付合同,宿主保存权威证据与预算;证据齐全后先封口、排空并取得执行器完整退出证明,再产出交付报告,模型回复不再当作验收。
- 原生 shell、内置浏览器与托管命令、第三方 MCP 共用同一执行许可和累计执行时间;`validation.maxRuns` 改为执行/返修批次语义,另设 `maxTurnSeconds` 墙钟上限。
- 所有工具可并发:移除 SDK 全局串行的 `apply_patch`/`update_plan` 注册,改由宿主 `agc_apply_patch`(官方 parser + 当前回合写许可 + 受控进程树 + 短项目事务)和 `agc_update_plan` 提供等价能力;真实请求目录里已无串行注册,长 MCP 与补丁/计划实测在同一响应内重叠。
- 付费提交与本地写入绑定原回合原租约:容量与同动作锁等待可取消,封口、取消或预算耗尽后零新增提交;已越过提交边界的请求保留 operation ID 走 GET 对账,不自动重放。
- 新增请求与工具分段计时、有界并行批读和首轮上下文预取,未知耗时不补零。
- 捆绑 Codex 0.147.0 → 0.155.1:固定版本收敛到 `build_support/codex_bundle.rs` 单一声明,vendor 解析源码按 `rust-v0.155.1` 逐字节重取并更新 UPSTREAM 证据,适配 0.155 统一 exec(`exec_command`/`write_stdin`);macOS 侧车最小系统版本仍为 15.0。

## 验证

- 生产 CLI → 捆绑 Codex 0.155.1 → 本地 Responses/MCP 夹具 9/9:补丁、完成收尾、批次、只读/可写 MCP 并发、原生命令并发、原生资源并发、统一 exec 会话、期限终止(真实重叠 775 / 764 / 999 ms)。
- 真实模型目录 2/2;vendor 上游库 111 项;宿主定向与回归 106 项;真实 Windows 进程与取消 21 项;Node 侧门禁 52 项。
- 发行载荷:artifact-only 重建 NSIS,解包验证侧车清单 `codex-cli 0.155.1`、6 个组件哈希、打包后 `bin/codex.exe --version`、Node 运行时 2130 文件与双端 PNG、`--environment-check` ready。
- `check-config`、TypeScript、编码(4991 文件)、文档索引、`git diff --check`、`cargo fmt --check` 全部通过。

## 未覆盖

- 真实陶泥儿登录态 Provider 生成尚未执行(本机 `--llm-status` 返回 `authentication-required`)。
- macOS 侧车只在本机做静态 Mach-O 与清单解析,未在 macOS 上跑 `check-macos-bundle.mjs`。
- 全仓库聚合套件在本机仍因负载敏感的后台 mock-provider 用例而红,与本次改动无关:改动前的旧二进制同样失败,换单线程后相关用例 3/3 通过。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/439
2026-09-21 02:29:29 +08:00

2103 lines
78 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
const PREVIEW_SERVE_USAGE: &str = "用法:--preview-serve <本地项目绝对路径>";
#[derive(Debug, Eq, PartialEq)]
pub(crate) enum CliCommand {
LlmStatus,
EnvironmentCheck,
PreviewServe {
project_path: PathBuf,
},
AgentChat {
project_path: PathBuf,
agent_id: String,
prompt: String,
},
DirectCodexChat {
project_path: PathBuf,
prompt: String,
},
AgentTask {
project_path: PathBuf,
agent_id: String,
task: String,
initialize: bool,
},
SwarmChat {
project_path: PathBuf,
parent_agent_id: String,
initialize: bool,
run_profile: String,
supervisor_source: &'static str,
},
AgentEnqueue {
project_path: PathBuf,
agent_id: String,
run_id: String,
task: String,
initialize: bool,
},
AgentRuntimeStatus {
project_path: PathBuf,
agent_id: String,
},
AgentContextCompact {
project_path: PathBuf,
agent_id: String,
session_id: Option<String>,
},
AgentGoalStatus {
project_path: PathBuf,
agent_id: String,
session_id: String,
},
AgentGoalStart {
project_path: PathBuf,
agent_id: String,
session_id: String,
run_id: String,
initialize: bool,
},
AgentGoalEdit {
project_path: PathBuf,
agent_id: String,
session_id: String,
goal_id: String,
expected_revision: u64,
},
AgentGoalPause {
project_path: PathBuf,
agent_id: String,
session_id: String,
goal_id: String,
expected_revision: u64,
},
AgentGoalResume {
project_path: PathBuf,
agent_id: String,
session_id: String,
goal_id: String,
expected_revision: u64,
},
AgentGoalClear {
project_path: PathBuf,
agent_id: String,
session_id: String,
goal_id: String,
expected_revision: u64,
},
AgentConfirm {
project_path: PathBuf,
agent_id: String,
run_id: String,
action_id: String,
},
AgentCancel {
project_path: PathBuf,
agent_id: String,
run_id: String,
},
AgentRetry {
project_path: PathBuf,
agent_id: String,
run_id: String,
next_run_id: String,
},
AgentSteer {
project_path: PathBuf,
agent_id: String,
session_id: String,
run_id: String,
steer_id: String,
},
AgentResume {
project_path: PathBuf,
},
RunnerStatus,
RunnerShutdownIfIdle,
AgentRun {
project_path: PathBuf,
prompt: String,
wait_for_enter: bool,
},
}
#[derive(Debug, Deserialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
struct CliAgentGoalPayload {
outcome: String,
constraints: Vec<String>,
verification: Vec<String>,
}
impl CliCommand {
pub(crate) fn requires_external_agent_runner(&self) -> bool {
matches!(
self,
Self::AgentTask { .. }
| Self::SwarmChat { .. }
| Self::AgentEnqueue { .. }
| Self::AgentContextCompact { .. }
| Self::AgentConfirm { .. }
| Self::AgentCancel { .. }
| Self::AgentRetry { .. }
| Self::AgentSteer { .. }
| Self::AgentGoalStart { .. }
| Self::AgentGoalEdit { .. }
| Self::AgentGoalPause { .. }
| Self::AgentGoalResume { .. }
| Self::AgentGoalClear { .. }
| Self::AgentResume { .. }
| Self::RunnerShutdownIfIdle
)
}
pub(crate) fn is_read_only_status(&self) -> bool {
matches!(
self,
Self::AgentRuntimeStatus { .. } | Self::AgentGoalStatus { .. } | Self::RunnerStatus
)
}
pub(crate) fn requires_started_external_agent_runner(&self) -> bool {
self.requires_external_agent_runner()
&& !matches!(
self,
Self::AgentCancel { .. } | Self::AgentResume { .. } | Self::RunnerShutdownIfIdle
)
}
fn project_path_mut(&mut self) -> Option<(&mut PathBuf, bool)> {
match self {
Self::AgentTask {
project_path,
initialize,
..
}
| Self::SwarmChat {
project_path,
initialize,
..
}
| Self::AgentEnqueue {
project_path,
initialize,
..
}
| Self::AgentGoalStart {
project_path,
initialize,
..
} => Some((project_path, *initialize)),
Self::AgentChat { project_path, .. }
| Self::DirectCodexChat { project_path, .. }
| Self::AgentRuntimeStatus { project_path, .. }
| Self::AgentContextCompact { project_path, .. }
| Self::AgentGoalStatus { project_path, .. }
| Self::AgentGoalEdit { project_path, .. }
| Self::AgentGoalPause { project_path, .. }
| Self::AgentGoalResume { project_path, .. }
| Self::AgentGoalClear { project_path, .. }
| Self::AgentConfirm { project_path, .. }
| Self::AgentCancel { project_path, .. }
| Self::AgentRetry { project_path, .. }
| Self::AgentSteer { project_path, .. }
| Self::AgentResume { project_path }
| Self::PreviewServe { project_path }
| Self::AgentRun { project_path, .. } => Some((project_path, false)),
Self::LlmStatus
| Self::EnvironmentCheck
| Self::RunnerStatus
| Self::RunnerShutdownIfIdle => None,
}
}
}
fn canonicalize_cli_path(
path: &Path,
label: &str,
allow_missing_leaf: bool,
) -> Result<PathBuf, String> {
if !path.is_absolute() {
return Err(format!("{label} 必须是绝对路径"));
}
match fs::canonicalize(path) {
Ok(path) => Ok(path),
Err(error) if allow_missing_leaf && error.kind() == std::io::ErrorKind::NotFound => {
let parent = path
.parent()
.ok_or_else(|| format!("{label} 缺少可解析的父目录"))?;
let file_name = path
.file_name()
.ok_or_else(|| format!("{label} 缺少目录名"))?;
let parent = fs::canonicalize(parent).map_err(|parent_error| {
format!(
"解析 {label} 父目录失败:{}: {parent_error}",
parent.display()
)
})?;
Ok(parent.join(file_name))
}
Err(error) => Err(format!("解析 {label} 失败:{}: {error}", path.display())),
}
}
pub(crate) fn prepare_cli_command_paths(
command: &mut CliCommand,
runtime_config_dir: Option<&Path>,
) -> Result<Option<PathBuf>, String> {
let project_path = if let Some((path, allow_missing_leaf)) = command.project_path_mut() {
let canonical = canonicalize_cli_path(path, "本地项目路径", allow_missing_leaf)?;
*path = canonical.clone();
Some(canonical)
} else {
None
};
let config_dir = runtime_config_dir
.map(|path| canonicalize_cli_path(path, "--config-dir", true))
.transpose()?;
if command.requires_external_agent_runner() && config_dir.is_none() {
return Err(
"Agent Runtime 写命令必须显式传入 --config-dir <项目外 AppData 绝对路径>".to_string(),
);
}
if let (Some(config_dir), Some(project_path)) = (&config_dir, &project_path) {
validate_game_creator_runtime_config_dir_outside_project(config_dir, project_path)?;
}
Ok(config_dir)
}
pub(crate) fn take_cli_runtime_config_dir(
args: &mut Vec<String>,
) -> Result<Option<PathBuf>, String> {
let positions = args
.iter()
.enumerate()
.filter_map(|(index, arg)| (arg == "--config-dir").then_some(index))
.collect::<Vec<_>>();
if positions.len() > 1 {
return Err("--config-dir 只能指定一次".to_string());
}
let Some(index) = positions.first().copied() else {
return Ok(None);
};
let value = args
.get(index + 1)
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "--config-dir 缺少目录路径".to_string())?;
let path = PathBuf::from(value);
if !path.is_absolute() {
return Err("--config-dir 必须是绝对路径".to_string());
}
args.drain(index..=index + 1);
Ok(Some(path))
}
pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) -> Vec<String> {
let mut lines = vec![
format!("agent.mode={}", status.agent_mode),
format!("llm.configured={}", status.configured),
format!(
"llm.accountCredentialState={}",
status.account_credential_state
),
format!("llm.officialRouteLocked={}", status.official_route_locked),
format!("llm.reasoningEffort={}", status.reasoning_effort),
format!("llm.stream={}", status.stream),
format!("llm.contextWindowTokens={}", status.context_window_tokens),
format!(
"llm.autoCompactTokenLimit={}",
status.auto_compact_token_limit
),
format!(
"llm.toolOutputTokenLimit={}",
status.tool_output_token_limit
),
format!("llm.requestTimeoutMs={}", status.request_timeout_ms),
format!("llm.maxRetries={}", status.max_retries),
format!("llm.retryBackoffMs={}", status.retry_backoff_ms),
];
if status.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
lines.push(format!(
"llm.controlledWebSearchEnabled={}",
status.web_search_enabled
));
lines.push("llm.codexNativeWebSearch=disabled".to_string());
} else {
lines.push(format!(
"llm.webSearchEnabled={}",
status.web_search_enabled
));
}
for agent in &status.agents {
lines.push(format!(
"llm.agent.{}.configured={}",
agent.agent_id, agent.configured
));
lines.push(format!(
"llm.agent.{}.accountCredentialState={}",
agent.agent_id, agent.account_credential_state
));
lines.push(format!(
"llm.agent.{}.officialRouteLocked={}",
agent.agent_id, agent.official_route_locked
));
lines.push(format!(
"llm.agent.{}.reasoningEffort={}",
agent.agent_id, agent.reasoning_effort
));
lines.push(format!(
"llm.agent.{}.stream={}",
agent.agent_id, agent.stream
));
if agent.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
lines.push(format!(
"llm.agent.{}.controlledWebSearchEnabled={}",
agent.agent_id, agent.web_search_enabled
));
lines.push(format!(
"llm.agent.{}.codexNativeWebSearch=disabled",
agent.agent_id
));
} else {
lines.push(format!(
"llm.agent.{}.webSearchEnabled={}",
agent.agent_id, agent.web_search_enabled
));
}
lines.push(format!(
"llm.agent.{}.contextWindowTokens={}",
agent.agent_id, agent.context_window_tokens
));
lines.push(format!(
"llm.agent.{}.autoCompactTokenLimit={}",
agent.agent_id, agent.auto_compact_token_limit
));
lines.push(format!(
"llm.agent.{}.toolOutputTokenLimit={}",
agent.agent_id, agent.tool_output_token_limit
));
lines.push(format!(
"llm.agent.{}.requestTimeoutMs={}",
agent.agent_id, agent.request_timeout_ms
));
lines.push(format!(
"llm.agent.{}.maxRetries={}",
agent.agent_id, agent.max_retries
));
lines.push(format!(
"llm.agent.{}.retryBackoffMs={}",
agent.agent_id, agent.retry_backoff_ms
));
if let Some(error) = agent.error.as_deref() {
lines.push(format!("llm.agent.{}.error={error}", agent.agent_id));
}
}
if let Some(error) = status.error.as_deref() {
lines.push(format!("llm.error={error}"));
}
lines
}
fn read_cli_agent_steer_instruction(reader: &mut impl Read) -> Result<String, String> {
const MAX_INSTRUCTION_BYTES: usize = 4 * 1024;
const MAX_STDIN_BYTES: u64 = 8 * 1024;
let mut bytes = Vec::new();
reader
.take(MAX_STDIN_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("从 stdin 读取 Agent 追加指令失败:{error}"))?;
if bytes.len() as u64 > MAX_STDIN_BYTES {
return Err(format!(
"Agent 追加指令 stdin 超过 {MAX_STDIN_BYTES} 字节上限"
));
}
let instruction = String::from_utf8(bytes)
.map_err(|_| "Agent 追加指令 stdin 必须是 UTF-8 文本".to_string())?;
let instruction = instruction.trim();
if instruction.is_empty() {
return Err("Agent 追加指令 stdin 不能为空".to_string());
}
if instruction.len() > MAX_INSTRUCTION_BYTES {
return Err(format!(
"Agent 追加指令 stdin 超过 {MAX_INSTRUCTION_BYTES} 字节上限"
));
}
Ok(instruction.to_string())
}
fn read_cli_agent_goal_payload(reader: &mut impl Read) -> Result<CliAgentGoalPayload, String> {
const MAX_STDIN_BYTES: u64 = 64 * 1024;
let mut bytes = Vec::new();
reader
.take(MAX_STDIN_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("从 stdin 读取 Agent Goal JSON 失败:{error}"))?;
if bytes.len() as u64 > MAX_STDIN_BYTES {
return Err(format!("Agent Goal stdin 超过 {MAX_STDIN_BYTES} 字节上限"));
}
if bytes.iter().all(u8::is_ascii_whitespace) {
return Err("Agent Goal stdin 不能为空".to_string());
}
let mut payload = serde_json::from_slice::<CliAgentGoalPayload>(&bytes)
.map_err(|error| format!("Agent Goal stdin 必须是结构化 JSON{error}"))?;
payload.outcome = payload.outcome.trim().to_string();
if payload.outcome.is_empty() {
return Err("Agent Goal outcome 不能为空".to_string());
}
Ok(payload)
}
fn parse_cli_agent_goal_revision(value: &str, usage: &str) -> Result<u64, String> {
let revision = value.parse::<u64>().map_err(|_| usage.to_string())?;
if revision == 0 {
return Err(usage.to_string());
}
Ok(revision)
}
pub(crate) fn parse_cli_command(args: &[String]) -> Result<Option<CliCommand>, String> {
if args.first().map(String::as_str) == Some("--environment-check") {
if args.len() != 1 {
return Err("用法:--environment-check".into());
}
return Ok(Some(CliCommand::EnvironmentCheck));
}
if args.first().map(String::as_str) == Some("--preview-serve") {
if args.len() != 2 || args[1].trim().is_empty() {
return Err(PREVIEW_SERVE_USAGE.to_string());
}
return Ok(Some(CliCommand::PreviewServe {
project_path: PathBuf::from(&args[1]),
}));
}
if args.first().map(String::as_str) == Some("--llm-status") {
return Ok(Some(CliCommand::LlmStatus));
}
if args.first().map(String::as_str) == Some("--runner-status") {
if args.len() != 1 {
return Err("用法:--runner-status".to_string());
}
return Ok(Some(CliCommand::RunnerStatus));
}
if args.first().map(String::as_str) == Some("--runner-shutdown-if-idle") {
if args.len() != 1 {
return Err("用法:--runner-shutdown-if-idle".to_string());
}
return Ok(Some(CliCommand::RunnerShutdownIfIdle));
}
if args.first().map(String::as_str) == Some("--agent-runtime-status") {
if args.len() != 3 {
return Err("用法:--agent-runtime-status <本地项目绝对路径> <agentId>".to_string());
}
return Ok(Some(CliCommand::AgentRuntimeStatus {
project_path: PathBuf::from(&args[1]),
agent_id: args[2].trim().to_string(),
}));
}
if args.first().map(String::as_str) == Some("--agent-context-compact") {
const USAGE: &str =
"用法:--agent-context-compact <本地项目绝对路径> <agentId> [sessionId]";
if !(args.len() == 3 || args.len() == 4)
|| args[1..].iter().any(|value| value.trim().is_empty())
{
return Err(USAGE.to_string());
}
return Ok(Some(CliCommand::AgentContextCompact {
project_path: PathBuf::from(&args[1]),
agent_id: args[2].trim().to_string(),
session_id: args.get(3).map(|value| value.trim().to_string()),
}));
}
if args.first().map(String::as_str) == Some("--agent-goal-status") {
const USAGE: &str = "用法:--agent-goal-status <本地项目绝对路径> <agentId> <sessionId>";
if args.len() != 4 || args[1..].iter().any(|value| value.trim().is_empty()) {
return Err(USAGE.to_string());
}
return Ok(Some(CliCommand::AgentGoalStatus {
project_path: PathBuf::from(&args[1]),
agent_id: args[2].trim().to_string(),
session_id: args[3].trim().to_string(),
}));
}
if args.first().map(String::as_str) == Some("--agent-goal-start") {
const USAGE: &str = "用法:--agent-goal-start [--init] <本地项目绝对路径> <agentId> <sessionId> <runId> --stdin";
let mut rest = args[1..].to_vec();
let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") {
rest.remove(index);
true
} else {
false
};
if rest.len() != 5
|| rest.last().map(String::as_str) != Some("--stdin")
|| rest[..4].iter().any(|value| value.trim().is_empty())
{
return Err(USAGE.to_string());
}
return Ok(Some(CliCommand::AgentGoalStart {
project_path: PathBuf::from(&rest[0]),
agent_id: rest[1].trim().to_string(),
session_id: rest[2].trim().to_string(),
run_id: rest[3].trim().to_string(),
initialize,
}));
}
if args.first().map(String::as_str) == Some("--agent-goal-edit") {
const USAGE: &str = "用法:--agent-goal-edit <本地项目绝对路径> <agentId> <sessionId> <goalId> <revision> --stdin";
if args.len() != 7
|| args.last().map(String::as_str) != Some("--stdin")
|| args[1..5].iter().any(|value| value.trim().is_empty())
{
return Err(USAGE.to_string());
}
return Ok(Some(CliCommand::AgentGoalEdit {
project_path: PathBuf::from(&args[1]),
agent_id: args[2].trim().to_string(),
session_id: args[3].trim().to_string(),
goal_id: args[4].trim().to_string(),
expected_revision: parse_cli_agent_goal_revision(&args[5], USAGE)?,
}));
}
if args.first().map(String::as_str) == Some("--agent-goal-pause") {
const USAGE: &str =
"用法:--agent-goal-pause <本地项目绝对路径> <agentId> <sessionId> <goalId> <revision>";
if args.len() != 6 || args[1..5].iter().any(|value| value.trim().is_empty()) {
return Err(USAGE.to_string());
}
return Ok(Some(CliCommand::AgentGoalPause {
project_path: PathBuf::from(&args[1]),
agent_id: args[2].trim().to_string(),
session_id: args[3].trim().to_string(),
goal_id: args[4].trim().to_string(),
expected_revision: parse_cli_agent_goal_revision(&args[5], USAGE)?,
}));
}
if args.first().map(String::as_str) == Some("--agent-goal-resume") {
const USAGE: &str = "用法:--agent-goal-resume <本地项目绝对路径> <agentId> <sessionId> <goalId> <revision>";
if args.len() != 6 || args[1..5].iter().any(|value| value.trim().is_empty()) {
return Err(USAGE.to_string());
}
return Ok(Some(CliCommand::AgentGoalResume {
project_path: PathBuf::from(&args[1]),
agent_id: args[2].trim().to_string(),
session_id: args[3].trim().to_string(),
goal_id: args[4].trim().to_string(),
expected_revision: parse_cli_agent_goal_revision(&args[5], USAGE)?,
}));
}
if args.first().map(String::as_str) == Some("--agent-goal-clear") {
const USAGE: &str =
"用法:--agent-goal-clear <本地项目绝对路径> <agentId> <sessionId> <goalId> <revision>";
if args.len() != 6 || args[1..5].iter().any(|value| value.trim().is_empty()) {
return Err(USAGE.to_string());
}
return Ok(Some(CliCommand::AgentGoalClear {
project_path: PathBuf::from(&args[1]),
agent_id: args[2].trim().to_string(),
session_id: args[3].trim().to_string(),
goal_id: args[4].trim().to_string(),
expected_revision: parse_cli_agent_goal_revision(&args[5], USAGE)?,
}));
}
if args.first().map(String::as_str) == Some("--agent-confirm") {
if args.len() != 5 {
return Err(
"用法:--agent-confirm <本地项目绝对路径> <agentId> <runId> <actionId>".to_string(),
);
}
return Ok(Some(CliCommand::AgentConfirm {
project_path: PathBuf::from(&args[1]),
agent_id: args[2].trim().to_string(),
run_id: args[3].trim().to_string(),
action_id: args[4].trim().to_string(),
}));
}
if args.first().map(String::as_str) == Some("--agent-cancel") {
const USAGE: &str = "用法:--agent-cancel <本地项目绝对路径> <agentId> <runId>";
if args.len() != 4 || args[1..].iter().any(|value| value.trim().is_empty()) {
return Err(USAGE.to_string());
}
return Ok(Some(CliCommand::AgentCancel {
project_path: PathBuf::from(&args[1]),
agent_id: args[2].trim().to_string(),
run_id: args[3].trim().to_string(),
}));
}
if args.first().map(String::as_str) == Some("--agent-retry") {
const USAGE: &str = "用法:--agent-retry <本地项目绝对路径> <agentId> <runId> <nextRunId>";
if args.len() != 5 || args[1..].iter().any(|value| value.trim().is_empty()) {
return Err(USAGE.to_string());
}
return Ok(Some(CliCommand::AgentRetry {
project_path: PathBuf::from(&args[1]),
agent_id: args[2].trim().to_string(),
run_id: args[3].trim().to_string(),
next_run_id: args[4].trim().to_string(),
}));
}
if args.first().map(String::as_str) == Some("--agent-steer") {
const USAGE: &str = "用法:--agent-steer <本地项目绝对路径> <agentId> <sessionId> <runId> <steerId> --stdin";
if args.len() != 7 || args.last().map(String::as_str) != Some("--stdin") {
return Err(USAGE.to_string());
}
let values = &args[1..6];
if values.iter().any(|value| value.trim().is_empty()) {
return Err(USAGE.to_string());
}
return Ok(Some(CliCommand::AgentSteer {
project_path: PathBuf::from(&values[0]),
agent_id: values[1].trim().to_string(),
session_id: values[2].trim().to_string(),
run_id: values[3].trim().to_string(),
steer_id: values[4].trim().to_string(),
}));
}
if args.first().map(String::as_str) == Some("--agent-resume") {
if args.len() != 2 {
return Err("用法:--agent-resume <本地项目绝对路径>".to_string());
}
return Ok(Some(CliCommand::AgentResume {
project_path: PathBuf::from(&args[1]),
}));
}
if args.first().map(String::as_str) == Some("--agent-enqueue") {
let mut rest = args[1..].to_vec();
let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") {
rest.remove(index);
true
} else {
false
};
if rest.len() < 4 {
return Err(
"用法:--agent-enqueue [--init] <本地项目绝对路径> <agentId> <runId> <任务>"
.to_string(),
);
}
let task = rest[3..].join(" ");
if task.trim().is_empty() {
return Err("Agent 任务不能为空".to_string());
}
return Ok(Some(CliCommand::AgentEnqueue {
project_path: PathBuf::from(&rest[0]),
agent_id: rest[1].trim().to_string(),
run_id: rest[2].trim().to_string(),
task: task.trim().to_string(),
initialize,
}));
}
if args.first().map(String::as_str) == Some("--agent-chat") {
let project_path = args.get(1).map(String::as_str).ok_or_else(|| {
"用法:--agent-chat <本地项目绝对路径> <agentId> <聊天内容>".to_string()
})?;
let agent_id = args.get(2).map(String::as_str).ok_or_else(|| {
"用法:--agent-chat <本地项目绝对路径> <agentId> <聊天内容>".to_string()
})?;
if args.len() < 4 {
return Err("用法:--agent-chat <本地项目绝对路径> <agentId> <聊天内容>".to_string());
}
let prompt = args[3..].join(" ");
let prompt = prompt.trim();
if prompt.is_empty() {
return Err("聊天内容不能为空".to_string());
}
return Ok(Some(CliCommand::AgentChat {
project_path: PathBuf::from(project_path),
agent_id: agent_id.trim().to_string(),
prompt: prompt.to_string(),
}));
}
if args.first().map(String::as_str) == Some("--direct-codex-chat") {
if args.len() < 3 {
return Err("用法:--direct-codex-chat <本地项目绝对路径> <聊天内容>".to_string());
}
let prompt = args[2..].join(" ");
if prompt.trim().is_empty() {
return Err("聊天内容不能为空".to_string());
}
return Ok(Some(CliCommand::DirectCodexChat {
project_path: PathBuf::from(&args[1]),
prompt: prompt.trim().to_string(),
}));
}
if args.first().map(String::as_str) == Some("--swarm-chat") {
const USAGE: &str = "用法:--swarm-chat [--init] [--autonomous-game-build] <本地项目绝对路径> [parentAgentId]";
let mut rest = args[1..].to_vec();
let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") {
rest.remove(index);
true
} else {
false
};
let autonomous_game_build = match rest
.iter()
.filter(|arg| arg.as_str() == "--autonomous-game-build")
.count()
{
0 => false,
1 => {
let index = rest
.iter()
.position(|arg| arg == "--autonomous-game-build")
.expect("counted autonomous game build flag");
rest.remove(index);
true
}
_ => return Err(USAGE.to_string()),
};
if !(1..=2).contains(&rest.len()) || rest.iter().any(|value| value.trim().is_empty()) {
return Err(USAGE.to_string());
}
return Ok(Some(CliCommand::SwarmChat {
project_path: PathBuf::from(&rest[0]),
parent_agent_id: rest
.get(1)
.map(|value| value.trim().to_string())
.unwrap_or_else(|| GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()),
initialize,
run_profile: if autonomous_game_build {
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD.to_string()
} else {
AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string()
},
supervisor_source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
}));
}
if args.first().map(String::as_str) == Some("--agent-task") {
let mut rest = args[1..].to_vec();
let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") {
rest.remove(index);
true
} else {
false
};
let project_path = rest.first().map(String::as_str).ok_or_else(|| {
"用法:--agent-task [--init] <本地项目绝对路径> <agentId> <任务>".to_string()
})?;
let agent_id = rest.get(1).map(String::as_str).ok_or_else(|| {
"用法:--agent-task [--init] <本地项目绝对路径> <agentId> <任务>".to_string()
})?;
if rest.len() < 3 {
return Err(
"用法:--agent-task [--init] <本地项目绝对路径> <agentId> <任务>".to_string(),
);
}
let task = rest[2..].join(" ");
let task = task.trim();
if task.is_empty() {
return Err("Agent 任务不能为空".to_string());
}
return Ok(Some(CliCommand::AgentTask {
project_path: PathBuf::from(project_path),
agent_id: agent_id.trim().to_string(),
task: task.to_string(),
initialize,
}));
}
if args.first().map(String::as_str) != Some("--agent-run") {
return Ok(None);
}
let mut rest = args[1..].to_vec();
let wait_for_enter = if let Some(index) = rest.iter().position(|arg| arg == "--no-wait") {
rest.remove(index);
false
} else {
true
};
let project_path = rest
.first()
.map(String::as_str)
.ok_or_else(|| "用法:--agent-run [--no-wait] <本地项目绝对路径> <创作需求>".to_string())?;
if rest.len() < 2 {
return Err("用法:--agent-run [--no-wait] <本地项目绝对路径> <创作需求>".to_string());
}
let prompt = rest[1..].join(" ");
let prompt = prompt.trim();
if prompt.is_empty() {
return Err("创作需求不能为空".to_string());
}
Ok(Some(CliCommand::AgentRun {
project_path: PathBuf::from(project_path),
prompt: prompt.to_string(),
wait_for_enter,
}))
}
fn strip_agent_runtime_cli_private_paths(value: &mut serde_json::Value) {
match value {
serde_json::Value::Array(values) => {
for value in values {
strip_agent_runtime_cli_private_paths(value);
}
}
serde_json::Value::Object(object) => {
object.remove("sessionPath");
object.remove("eventPath");
object.remove("taskPath");
for value in object.values_mut() {
strip_agent_runtime_cli_private_paths(value);
}
}
_ => {}
}
}
pub(crate) fn start_cli_agent_task_at(
project_path: &Path,
agent_id: &str,
task: &str,
run_id: &str,
) -> Result<AgentRuntimeResult, String> {
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
start_game_creator_supervisor_background_task_for_session_at(
project_path,
None,
task,
run_id,
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
AGENT_RUNTIME_RUN_PROFILE_STANDARD,
)
} else {
start_game_creator_agent_background_task_at(project_path, agent_id, task, run_id)
}
}
fn serialize_agent_runtime_cli_payload<T: serde::Serialize>(payload: &T) -> Result<String, String> {
let mut value = serde_json::to_value(payload)
.map_err(|error| format!("序列化 Agent Runtime 状态失败:{error}"))?;
strip_agent_runtime_cli_private_paths(&mut value);
serde_json::to_string(&value).map_err(|error| format!("序列化 Agent Runtime 状态失败:{error}"))
}
pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> {
match command {
CliCommand::EnvironmentCheck => {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|_| "无法启动环境预检".to_string())?;
let report = runtime.block_on(crate::environment_check::host_web_creation_preflight());
println!(
"{}",
serde_json::to_string(&report).map_err(|_| "无法序列化环境预检".to_string())?
);
if report["status"] == "ready" {
Ok(())
} else {
Err("Web 游戏环境预检未通过;未启动生成".into())
}
}
CliCommand::LlmStatus => {
let status = check_game_creator_llm_config_from_config();
for line in game_creator_llm_status_lines(&status) {
println!("{line}");
}
if status.configured {
Ok(())
} else {
Err("LLM 配置未就绪".to_string())
}
}
CliCommand::PreviewServe { project_path } => {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|error| format!("创建 CLI runtime 失败:{error}"))?;
let registry = game_creator_preview_registry();
let preview = start_local_game_preview_at(&project_path, &registry)?;
println!("preview.running");
println!("projectPath={}", project_path.display());
println!("previewUrl={}", preview.url);
let wait_result = std::io::stdout()
.flush()
.map_err(|error| format!("刷新本地预览 CLI 输出失败:{error}"))
.and_then(|()| {
runtime
.block_on(tokio::signal::ctrl_c())
.map_err(|error| format!("等待 Ctrl+C 失败:{error}"))
});
let stop_result = stop_local_game_preview_for_root(Some(&project_path), &registry)
.map(|_| ())
.map_err(|error| format!("停止本地预览失败:{error}"));
match (wait_result, stop_result) {
(Ok(()), Ok(())) => Ok(()),
(Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error),
(Err(wait_error), Err(stop_error)) => {
Err(format!("{wait_error};同时{stop_error}"))
}
}
}
CliCommand::AgentChat {
project_path,
agent_id,
prompt,
} => {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|error| format!("创建 CLI runtime 失败:{error}"))?;
let reply = runtime.block_on(chat_with_game_creator_role_agent_at(
&project_path,
&agent_id,
&prompt,
))?;
println!("agent.chat.completed");
println!("projectPath={}", project_path.display());
println!("agentId={agent_id}");
println!("replyText={}", reply.reply_text);
Ok(())
}
CliCommand::DirectCodexChat {
project_path,
prompt,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", true)?;
if !project_path.join(".agent/manifest.json").is_file() {
let project_name = project_path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("Codex 直连项目");
init_local_game_project_at(
&project_path,
&format!("direct-codex-{}", unix_millis()),
project_name,
)?;
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|error| format!("创建 CLI runtime 失败:{error}"))?;
let reply_result = runtime
.block_on(async { run_direct_game_creator_turn_at(&project_path, &prompt).await });
let shutdown_result = shutdown_game_creator_codex_app_servers();
let reply = reply_result?;
shutdown_result?;
println!("direct-codex.chat.completed");
println!("projectPath={}", project_path.display());
println!("replyText={reply}");
Ok(())
}
CliCommand::AgentTask {
project_path,
agent_id,
task,
initialize,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", initialize)?;
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
if initialize && !project_path.join(".agent/manifest.json").is_file() {
let project_name = project_path
.file_name()
.and_then(|value| value.to_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("CLI Agent 项目");
init_local_game_project_at(
&project_path,
&format!("cli-agent-{}", unix_millis()),
project_name,
)?;
}
if !project_path.join(".agent/manifest.json").is_file() {
return Err("项目尚未初始化;请先在 App 中创建项目,或显式传入 --init".to_string());
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|error| format!("创建 CLI runtime 失败:{error}"))?;
let run_id = format!("cli-{agent_id}-{}", unix_millis());
let terminal = runtime.block_on(async {
let started = start_cli_agent_task_at(&project_path, &agent_id, &task, &run_id)?;
let canonical_run_id = started.state.run_id.clone();
let deadline = std::time::Instant::now() + Duration::from_secs(600);
loop {
let current = read_game_creator_agent_runtime_at(&project_path, &agent_id)?;
if current.state.run_id == canonical_run_id
&& (current.state.status == "idle"
|| current.state.status == "failed"
|| current.state.status == "waiting-for-confirmation"
|| current.state.status == "waiting-for-user-input")
{
break Ok::<AgentRuntimeState, String>(current.state);
}
if std::time::Instant::now() >= deadline {
break Err(format!(
"等待单 Agent 任务超时:agentId={agent_id} runId={canonical_run_id}"
));
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
})?;
println!("agent.task.terminal");
println!("projectPath={}", project_path.display());
println!("agentId={agent_id}");
println!("runId={}", terminal.run_id);
println!("status={}", terminal.status);
println!("phase={}", terminal.phase);
if let Some(reply) = terminal.last_response.as_deref() {
println!("replyText={reply}");
}
if let Some(pending) = terminal.pending_tool_action.as_ref() {
println!("pendingActionId={}", pending.action_id);
println!("pendingTool={}", pending.tool);
if let Some(summary) = pending.input_summary.as_deref() {
println!("pendingInput={summary}");
}
}
if let Some(error) = terminal.error.as_deref() {
println!("error={error}");
}
if terminal.status == "idle" && terminal.phase == "completed" {
Ok(())
} else if terminal.status == "waiting-for-confirmation" {
Err("单 Agent 任务正在等待开发者确认,请在开发窗口继续".to_string())
} else if terminal.status == "waiting-for-user-input" {
Err("单 Agent 任务正在等待用户回答,请使用 agc:chat 继续".to_string())
} else {
Err(format!(
"单 Agent 任务未完成:{} / {}",
terminal.status, terminal.phase
))
}
}
CliCommand::SwarmChat {
project_path,
parent_agent_id,
initialize,
run_profile,
supervisor_source,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", initialize)?;
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
initialize_cli_agent_project(&project_path, initialize)?;
run_game_creator_swarm_chat_at(
&project_path,
&parent_agent_id,
&run_profile,
supervisor_source,
)
}
CliCommand::AgentEnqueue {
project_path,
agent_id,
run_id,
task,
initialize,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", initialize)?;
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
initialize_cli_agent_project(&project_path, initialize)?;
let runtime = start_game_creator_agent_background_task_at(
&project_path,
&agent_id,
&task,
&run_id,
)?;
println!("agent.enqueue.accepted");
println!("agentId={agent_id}");
println!("requestedRunId={run_id}");
println!(
"runtimeJson={}",
serialize_agent_runtime_cli_payload(&runtime)?
);
Ok(())
}
CliCommand::AgentRuntimeStatus {
project_path,
agent_id,
} => {
let runtime = read_game_creator_agent_runtime_at(&project_path, &agent_id)?;
println!(
"runtimeJson={}",
serialize_agent_runtime_cli_payload(&runtime)?
);
Ok(())
}
CliCommand::AgentContextCompact {
project_path,
agent_id,
session_id,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
let result = compact_external_agent_runner_context(
&project_path,
&agent_id,
session_id.as_deref(),
)?;
println!(
"contextCompactionJson={}",
serde_json::to_string(&result)
.map_err(|error| format!("序列化上下文压缩结果失败:{error}"))?
);
Ok(())
}
CliCommand::AgentGoalStatus {
project_path,
agent_id,
session_id,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
let goal = read_game_creator_agent_goal(
project_path.display().to_string(),
agent_id,
session_id,
)?;
println!("agent.goal.status");
println!("goalJson={}", serialize_agent_runtime_cli_payload(&goal)?);
Ok(())
}
CliCommand::AgentGoalStart {
project_path,
agent_id,
session_id,
run_id,
initialize,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", initialize)?;
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
if initialize && !project_path.join(".agent/manifest.json").is_file() {
let project_name = project_path
.file_name()
.and_then(|value| value.to_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("CLI Goal 项目");
init_local_game_project_at(
&project_path,
&format!("cli-goal-{}", unix_millis()),
project_name,
)?;
}
if !project_path.join(".agent/manifest.json").is_file() {
return Err("项目尚未初始化;请先在 App 中创建项目,或显式传入 --init".to_string());
}
let payload = read_cli_agent_goal_payload(&mut std::io::stdin().lock())?;
let result = start_game_creator_agent_goal(
project_path.display().to_string(),
agent_id,
Some(session_id),
payload.outcome,
payload.constraints,
payload.verification,
run_id,
)?;
println!("agent.goal.started");
println!(
"goalMutationJson={}",
serialize_agent_runtime_cli_payload(&result)?
);
Ok(())
}
CliCommand::AgentGoalEdit {
project_path,
agent_id,
session_id,
goal_id,
expected_revision,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
let payload = read_cli_agent_goal_payload(&mut std::io::stdin().lock())?;
let result = edit_game_creator_agent_goal(
project_path.display().to_string(),
agent_id,
session_id,
goal_id,
expected_revision,
payload.outcome,
payload.constraints,
payload.verification,
)?;
println!("agent.goal.edited");
println!(
"goalMutationJson={}",
serialize_agent_runtime_cli_payload(&result)?
);
Ok(())
}
CliCommand::AgentGoalPause {
project_path,
agent_id,
session_id,
goal_id,
expected_revision,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
let result = pause_game_creator_agent_goal(
project_path.display().to_string(),
agent_id,
session_id,
goal_id,
expected_revision,
)?;
println!("agent.goal.paused");
println!(
"goalMutationJson={}",
serialize_agent_runtime_cli_payload(&result)?
);
Ok(())
}
CliCommand::AgentGoalResume {
project_path,
agent_id,
session_id,
goal_id,
expected_revision,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
let result = resume_game_creator_agent_goal(
project_path.display().to_string(),
agent_id,
session_id,
goal_id,
expected_revision,
)?;
println!("agent.goal.resumed");
println!(
"goalMutationJson={}",
serialize_agent_runtime_cli_payload(&result)?
);
Ok(())
}
CliCommand::AgentGoalClear {
project_path,
agent_id,
session_id,
goal_id,
expected_revision,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
let result = clear_game_creator_agent_goal(
project_path.display().to_string(),
agent_id,
session_id,
goal_id,
expected_revision,
)?;
println!("agent.goal.cleared");
println!(
"goalMutationJson={}",
serialize_agent_runtime_cli_payload(&result)?
);
Ok(())
}
CliCommand::AgentConfirm {
project_path,
agent_id,
run_id,
action_id,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
let runtime = confirm_game_creator_agent_runtime_task_at(
&project_path,
&agent_id,
&run_id,
&action_id,
"真实 E2E CLI 精确确认",
)?;
println!("agent.confirm.accepted");
println!(
"runtimeJson={}",
serialize_agent_runtime_cli_payload(&runtime)?
);
Ok(())
}
CliCommand::AgentCancel {
project_path,
agent_id,
run_id,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
// Cancellation must remain available when a busy older Runner blocks a build
// handover. It writes the durable cancel tombstone/state locally; retry still
// requires the current executable's Runner after the old run becomes idle.
require_external_agent_runner_configured_for_cli_runtime_write(&project_path)?;
let runtime =
cancel_game_creator_agent_runtime_task_at(&project_path, &agent_id, &run_id)?;
println!("agent.cancel.accepted");
println!(
"runtimeJson={}",
serialize_agent_runtime_cli_payload(&runtime)?
);
Ok(())
}
CliCommand::AgentRetry {
project_path,
agent_id,
run_id,
next_run_id,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
let runtime = retry_game_creator_agent_runtime_task_at(
&project_path,
&agent_id,
&run_id,
&next_run_id,
)?;
println!("agent.retry.accepted");
println!(
"runtimeJson={}",
serialize_agent_runtime_cli_payload(&runtime)?
);
Ok(())
}
CliCommand::AgentSteer {
project_path,
agent_id,
session_id,
run_id,
steer_id,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
let instruction = read_cli_agent_steer_instruction(&mut std::io::stdin().lock())?;
let mut result = steer_game_creator_agent_runtime_task_at(
&project_path,
&agent_id,
&session_id,
&run_id,
&steer_id,
&instruction,
"cli",
)?;
if !result.provider_interrupted {
result.provider_interrupted =
steer_external_agent_runner(&project_path, &agent_id, &run_id, &steer_id)?;
}
println!("agent.steer.accepted");
println!("agentId={agent_id}");
println!("sessionId={session_id}");
println!("runId={run_id}");
println!("steerId={steer_id}");
println!(
"steerJson={}",
serialize_agent_runtime_cli_payload(&result)?
);
Ok(())
}
CliCommand::AgentResume { project_path } => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
let completed_finalizations_cleaned =
cleanup_game_creator_agent_runtime_completed_finalizations_at(&project_path)?;
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
let runtimes = resume_game_creator_agent_background_tasks_at(&project_path)?;
println!("agent.resume.accepted");
println!("completedFinalizationsCleaned={completed_finalizations_cleaned}");
println!(
"runtimesJson={}",
serialize_agent_runtime_cli_payload(&runtimes)?
);
Ok(())
}
CliCommand::RunnerStatus => {
println!(
"runnerJson={}",
serde_json::to_string(&read_external_agent_runner_status())
.map_err(|error| format!("序列化 Agent Runner 状态失败:{error}"))?
);
Ok(())
}
CliCommand::RunnerShutdownIfIdle => {
let stopped = shutdown_external_agent_runner_if_idle()?;
println!("runner.stopped={stopped}");
Ok(())
}
CliCommand::AgentRun {
project_path,
prompt,
wait_for_enter,
} => {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|error| format!("创建 CLI runtime 失败:{error}"))?;
let result =
runtime.block_on(generate_local_game_draft_at(&project_path, &prompt, None))?;
let (preview, stop) = start_local_game_preview_for_project(&project_path)?;
record_preview_state(
&project_path,
GameCreationAppPreviewStatus::Running,
Some(preview.url.clone()),
Some(preview.port),
)?;
append_preview_log(&project_path, "running", Some(&preview.url))?;
append_preview_start_trace_step(&project_path, &preview)?;
println!("agent.run.completed");
println!("projectPath={}", result.project_path);
println!("gameIndexPath={}", result.game_index_path);
println!("designPath={}", result.design_path);
println!(
"tracePath={}",
project_path.join(".agent/run.latest.json").display()
);
println!("previewUrl={}", preview.url);
if wait_for_enter {
println!("按 Enter 停止本地预览。");
let mut line = String::new();
let _ = std::io::stdin().read_line(&mut line);
}
let _ = stop.send(());
let _ = record_preview_state(
&project_path,
GameCreationAppPreviewStatus::Stopped,
None,
None,
);
let _ = append_preview_log(&project_path, "stopped", None);
let _ = append_preview_stop_trace_step(&project_path);
Ok(())
}
}
}
fn initialize_cli_agent_project(project_path: &Path, initialize: bool) -> Result<(), String> {
if initialize && !project_path.join(".agent/manifest.json").is_file() {
let project_name = project_path
.file_name()
.and_then(|value| value.to_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("CLI Agent 项目");
init_local_game_project_at(
project_path,
&format!("cli-agent-{}", unix_millis()),
project_name,
)?;
}
if !project_path.join(".agent/manifest.json").is_file() {
return Err("项目尚未初始化;请先在 App 中创建项目,或显式传入 --init".to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn environment_check_cli_accepts_no_project_script_or_credentials() {
assert_eq!(
parse_cli_command(&["--environment-check".into()]).unwrap(),
Some(CliCommand::EnvironmentCheck)
);
assert!(
parse_cli_command(&["--environment-check".into(), "user-script.js".into()]).is_err()
);
assert!(CliCommand::EnvironmentCheck.project_path_mut().is_none());
}
#[test]
fn runtime_cli_payload_omits_private_storage_paths_recursively() {
let payload = serde_json::json!({
"sessionPath": "/tmp/private/session.json",
"runtime": {
"eventPath": "/tmp/private/events.jsonl",
"state": {
"sessionId": "session-7",
"runId": "run-9"
}
},
"runtimes": [{
"taskPath": "/tmp/private/tasks.jsonl",
"state": { "status": "running" }
}]
});
let encoded = serialize_agent_runtime_cli_payload(&payload)
.expect("serialize redacted Runtime CLI payload");
assert!(!encoded.contains("/tmp/private"));
let parsed = serde_json::from_str::<serde_json::Value>(&encoded)
.expect("parse redacted Runtime CLI payload");
assert!(parsed.get("sessionPath").is_none());
assert!(parsed["runtime"].get("eventPath").is_none());
assert!(parsed["runtimes"][0].get("taskPath").is_none());
assert_eq!(parsed["runtime"]["state"]["sessionId"], "session-7");
assert_eq!(parsed["runtimes"][0]["state"]["status"], "running");
}
#[test]
fn parses_preview_serve_without_runner_or_config() {
let project_path = std::env::current_dir().expect("current directory");
let mut command = parse_cli_command(&[
"--preview-serve".to_string(),
project_path.display().to_string(),
])
.expect("parse preview serve")
.expect("preview serve command");
assert_eq!(
command,
CliCommand::PreviewServe {
project_path: project_path.clone(),
}
);
assert!(!command.requires_external_agent_runner());
assert!(!command.requires_started_external_agent_runner());
assert!(!command.is_read_only_status());
assert_eq!(
prepare_cli_command_paths(&mut command, None)
.expect("prepare preview serve without config dir"),
None
);
assert_eq!(
command,
CliCommand::PreviewServe {
project_path: fs::canonicalize(project_path).expect("canonical project path"),
}
);
}
#[test]
fn preview_serve_requires_exactly_one_non_empty_project_path() {
for args in [
vec!["--preview-serve"],
vec!["--preview-serve", " "],
vec!["--preview-serve", "/tmp/game-project", "/tmp/extra"],
] {
let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
assert_eq!(
parse_cli_command(&args),
Err(PREVIEW_SERVE_USAGE.to_string()),
"unexpected parse result for {args:?}"
);
}
}
#[test]
fn parses_agent_steer_with_stdin_only_contract() {
let command = parse_cli_command(&[
"--agent-steer".to_string(),
"/tmp/game-project".to_string(),
"code-prototype".to_string(),
"session-7".to_string(),
"run-9".to_string(),
"steer-11".to_string(),
"--stdin".to_string(),
])
.expect("parse agent steer")
.expect("agent steer command");
assert_eq!(
command,
CliCommand::AgentSteer {
project_path: PathBuf::from("/tmp/game-project"),
agent_id: "code-prototype".to_string(),
session_id: "session-7".to_string(),
run_id: "run-9".to_string(),
steer_id: "steer-11".to_string(),
}
);
assert!(command.requires_external_agent_runner());
}
#[test]
fn parses_agent_cancel_and_retry_with_explicit_run_identity() {
let project_path = PathBuf::from("/tmp/game-project");
let cancel = parse_cli_command(&[
"--agent-cancel".to_string(),
project_path.display().to_string(),
" project-supervisor ".to_string(),
" run-9 ".to_string(),
])
.expect("parse agent cancel")
.expect("agent cancel command");
assert_eq!(
cancel,
CliCommand::AgentCancel {
project_path: project_path.clone(),
agent_id: "project-supervisor".to_string(),
run_id: "run-9".to_string(),
}
);
assert!(cancel.requires_external_agent_runner());
assert!(!cancel.requires_started_external_agent_runner());
assert!(!cancel.is_read_only_status());
let retry = parse_cli_command(&[
"--agent-retry".to_string(),
project_path.display().to_string(),
" project-supervisor ".to_string(),
" run-9 ".to_string(),
" run-10 ".to_string(),
])
.expect("parse agent retry")
.expect("agent retry command");
assert_eq!(
retry,
CliCommand::AgentRetry {
project_path,
agent_id: "project-supervisor".to_string(),
run_id: "run-9".to_string(),
next_run_id: "run-10".to_string(),
}
);
assert!(retry.requires_external_agent_runner());
assert!(retry.requires_started_external_agent_runner());
assert!(!retry.is_read_only_status());
}
#[test]
fn agent_cancel_and_retry_reject_missing_or_blank_identity() {
for args in [
vec!["--agent-cancel"],
vec![
"--agent-cancel",
"/tmp/game-project",
"project-supervisor",
" ",
],
vec![
"--agent-retry",
"/tmp/game-project",
"project-supervisor",
"run-9",
],
vec![
"--agent-retry",
"/tmp/game-project",
"project-supervisor",
"run-9",
"\t",
],
] {
let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
assert!(
parse_cli_command(&args).is_err(),
"args should fail: {args:?}"
);
}
}
#[test]
fn agent_steer_rejects_missing_params_and_argv_instruction() {
assert!(parse_cli_command(&["--agent-steer".to_string()]).is_err());
assert!(parse_cli_command(&[
"--agent-steer".to_string(),
"/tmp/game-project".to_string(),
"code-prototype".to_string(),
"session-7".to_string(),
"run-9".to_string(),
"steer-11".to_string(),
])
.is_err());
assert!(parse_cli_command(&[
"--agent-steer".to_string(),
"/tmp/game-project".to_string(),
"code-prototype".to_string(),
"session-7".to_string(),
"run-9".to_string(),
"steer-11".to_string(),
"正文不能出现在 argv".to_string(),
])
.is_err());
}
#[test]
fn reads_agent_steer_instruction_only_from_stdin() {
let mut stdin = Cursor::new(" 先停下当前方案,改用键盘操作。\n");
assert_eq!(
read_cli_agent_steer_instruction(&mut stdin).as_deref(),
Ok("先停下当前方案,改用键盘操作。")
);
let mut empty = Cursor::new(" \r\n\t");
assert!(read_cli_agent_steer_instruction(&mut empty).is_err());
let mut exact_with_newline = Cursor::new(format!("{}\n", "x".repeat(4 * 1024)));
assert_eq!(
read_cli_agent_steer_instruction(&mut exact_with_newline)
.expect("read exact-size instruction")
.len(),
4 * 1024
);
let mut oversized = Cursor::new(vec![b'x'; 4 * 1024 + 1]);
assert!(read_cli_agent_steer_instruction(&mut oversized).is_err());
}
#[test]
fn agent_steer_requires_external_config_dir() {
let mut command = CliCommand::AgentSteer {
project_path: std::env::current_dir().expect("current directory"),
agent_id: "code-prototype".to_string(),
session_id: "session-7".to_string(),
run_id: "run-9".to_string(),
steer_id: "steer-11".to_string(),
};
let error = prepare_cli_command_paths(&mut command, None)
.expect_err("agent steer must require config dir");
assert!(error.contains("--config-dir"));
}
#[test]
fn parses_all_non_interactive_goal_commands_with_explicit_cas_identity() {
let project_path = PathBuf::from("/tmp/game-project");
let status = parse_cli_command(&[
"--agent-goal-status".to_string(),
project_path.display().to_string(),
"code-prototype".to_string(),
"session-7".to_string(),
])
.expect("parse goal status")
.expect("goal status command");
assert_eq!(
status,
CliCommand::AgentGoalStatus {
project_path: project_path.clone(),
agent_id: "code-prototype".to_string(),
session_id: "session-7".to_string(),
}
);
assert!(status.is_read_only_status());
assert!(!status.requires_external_agent_runner());
let start = parse_cli_command(&[
"--agent-goal-start".to_string(),
project_path.display().to_string(),
"code-prototype".to_string(),
"session-7".to_string(),
"goal-run-9".to_string(),
"--stdin".to_string(),
])
.expect("parse goal start")
.expect("goal start command");
assert_eq!(
start,
CliCommand::AgentGoalStart {
project_path: project_path.clone(),
agent_id: "code-prototype".to_string(),
session_id: "session-7".to_string(),
run_id: "goal-run-9".to_string(),
initialize: false,
}
);
let mut initialized_start = parse_cli_command(&[
"--agent-goal-start".to_string(),
"--init".to_string(),
project_path.display().to_string(),
"code-prototype".to_string(),
"session-8".to_string(),
"goal-run-10".to_string(),
"--stdin".to_string(),
])
.expect("parse initialized goal start")
.expect("initialized goal start command");
assert_eq!(
initialized_start,
CliCommand::AgentGoalStart {
project_path: project_path.clone(),
agent_id: "code-prototype".to_string(),
session_id: "session-8".to_string(),
run_id: "goal-run-10".to_string(),
initialize: true,
}
);
let (_, initialize) = initialized_start
.project_path_mut()
.expect("initialized Goal start project path");
assert!(initialize);
let edit = parse_cli_command(&[
"--agent-goal-edit".to_string(),
project_path.display().to_string(),
"code-prototype".to_string(),
"session-7".to_string(),
"goal-11".to_string(),
"3".to_string(),
"--stdin".to_string(),
])
.expect("parse goal edit")
.expect("goal edit command");
assert_eq!(
edit,
CliCommand::AgentGoalEdit {
project_path: project_path.clone(),
agent_id: "code-prototype".to_string(),
session_id: "session-7".to_string(),
goal_id: "goal-11".to_string(),
expected_revision: 3,
}
);
let cas_commands = [
("--agent-goal-pause", "pause"),
("--agent-goal-resume", "resume"),
("--agent-goal-clear", "clear"),
];
for (flag, expected) in cas_commands {
let command = parse_cli_command(&[
flag.to_string(),
project_path.display().to_string(),
"code-prototype".to_string(),
"session-7".to_string(),
"goal-11".to_string(),
"3".to_string(),
])
.expect("parse Goal CAS command")
.expect("Goal CAS command");
match (expected, &command) {
(
"pause",
CliCommand::AgentGoalPause {
expected_revision, ..
},
)
| (
"resume",
CliCommand::AgentGoalResume {
expected_revision, ..
},
)
| (
"clear",
CliCommand::AgentGoalClear {
expected_revision, ..
},
) => {
assert_eq!(*expected_revision, 3)
}
_ => panic!("unexpected Goal CAS command: {command:?}"),
}
assert!(command.requires_external_agent_runner());
assert!(!command.is_read_only_status());
}
assert!(start.requires_external_agent_runner());
assert!(edit.requires_external_agent_runner());
}
#[test]
fn goal_cli_rejects_argv_bodies_bad_revisions_and_keeps_global_resume() {
assert!(parse_cli_command(&[
"--agent-goal-start".to_string(),
"/tmp/game-project".to_string(),
"code-prototype".to_string(),
"session-7".to_string(),
"goal-run-9".to_string(),
"正文不能出现在 argv".to_string(),
])
.is_err());
assert!(parse_cli_command(&[
"--agent-goal-edit".to_string(),
"/tmp/game-project".to_string(),
"code-prototype".to_string(),
"session-7".to_string(),
"goal-11".to_string(),
"0".to_string(),
"--stdin".to_string(),
])
.is_err());
assert!(parse_cli_command(&[
"--agent-goal-pause".to_string(),
"/tmp/game-project".to_string(),
"code-prototype".to_string(),
"session-7".to_string(),
"goal-11".to_string(),
"not-a-revision".to_string(),
])
.is_err());
assert_eq!(
parse_cli_command(&[
"--agent-resume".to_string(),
"/tmp/game-project".to_string(),
])
.expect("parse global agent resume")
.expect("global agent resume command"),
CliCommand::AgentResume {
project_path: PathBuf::from("/tmp/game-project"),
}
);
}
#[test]
fn reads_strict_structured_goal_payload_from_stdin() {
let mut stdin = Cursor::new(
r#"{
"outcome": " 完成首个可玩版本 ",
"constraints": ["不新增平行 Runtime"],
"verification": ["键盘与触屏均可完成一局"]
}"#,
);
assert_eq!(
read_cli_agent_goal_payload(&mut stdin).expect("read Goal JSON"),
CliAgentGoalPayload {
outcome: "完成首个可玩版本".to_string(),
constraints: vec!["不新增平行 Runtime".to_string()],
verification: vec!["键盘与触屏均可完成一局".to_string()],
}
);
for invalid in [
r#"{"outcome":"目标","constraints":[]}"#,
r#"{"outcome":"目标","constraints":[],"verification":[],"extra":true}"#,
r#"{"outcome":" ","constraints":[],"verification":[]}"#,
"not-json",
" ",
] {
let mut stdin = Cursor::new(invalid);
assert!(
read_cli_agent_goal_payload(&mut stdin).is_err(),
"invalid payload should fail: {invalid}"
);
}
let mut oversized = Cursor::new(vec![b'x'; 64 * 1024 + 1]);
assert!(read_cli_agent_goal_payload(&mut oversized).is_err());
}
#[test]
fn parses_swarm_chat_and_requires_external_config_dir() {
let project_path = std::env::current_dir().expect("current directory");
let mut command = parse_cli_command(&[
"--swarm-chat".to_string(),
"--init".to_string(),
project_path.display().to_string(),
"code-prototype".to_string(),
])
.expect("parse swarm chat")
.expect("swarm chat command");
assert_eq!(
command,
CliCommand::SwarmChat {
project_path,
parent_agent_id: "code-prototype".to_string(),
initialize: true,
run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(),
supervisor_source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
}
);
assert!(command.requires_external_agent_runner());
let error = prepare_cli_command_paths(&mut command, None)
.expect_err("swarm chat must require config dir");
assert!(error.contains("--config-dir"));
}
#[test]
fn parses_idle_runner_shutdown_without_starting_a_new_runner() {
let mut command = parse_cli_command(&["--runner-shutdown-if-idle".to_string()])
.expect("parse idle Runner shutdown")
.expect("idle Runner shutdown command");
assert_eq!(command, CliCommand::RunnerShutdownIfIdle);
assert!(command.requires_external_agent_runner());
assert!(!command.requires_started_external_agent_runner());
let error = prepare_cli_command_paths(&mut command, None)
.expect_err("idle Runner shutdown must require config dir");
assert!(error.contains("--config-dir"));
assert!(parse_cli_command(
&["--runner-shutdown-if-idle".to_string(), "extra".to_string(),]
)
.is_err());
}
#[test]
fn parses_agent_context_compaction_with_optional_session() {
assert_eq!(
parse_cli_command(&[
"--agent-context-compact".to_string(),
"/tmp/game-project".to_string(),
"code-prototype".to_string(),
"agent-session-code-prototype".to_string(),
])
.expect("parse context compaction"),
Some(CliCommand::AgentContextCompact {
project_path: PathBuf::from("/tmp/game-project"),
agent_id: "code-prototype".to_string(),
session_id: Some("agent-session-code-prototype".to_string()),
})
);
assert_eq!(
parse_cli_command(&[
"--agent-context-compact".to_string(),
"/tmp/game-project".to_string(),
"code-prototype".to_string(),
])
.expect("parse active-session compaction"),
Some(CliCommand::AgentContextCompact {
project_path: PathBuf::from("/tmp/game-project"),
agent_id: "code-prototype".to_string(),
session_id: None,
})
);
assert!(parse_cli_command(&[
"--agent-context-compact".to_string(),
"/tmp/game-project".to_string(),
])
.is_err());
}
#[test]
fn swarm_chat_defaults_to_project_supervisor() {
let project_path = std::env::current_dir().expect("current directory");
let command = parse_cli_command(&[
"--swarm-chat".to_string(),
project_path.display().to_string(),
])
.expect("parse supervisor chat")
.expect("supervisor chat command");
assert_eq!(
command,
CliCommand::SwarmChat {
project_path,
parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
initialize: false,
run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(),
supervisor_source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
}
);
}
#[test]
fn swarm_chat_autonomous_game_build_flag_selects_autonomous_profile() {
let project_path = std::env::current_dir().expect("current directory");
let command = parse_cli_command(&[
"--swarm-chat".to_string(),
project_path.display().to_string(),
"--autonomous-game-build".to_string(),
])
.expect("parse autonomous supervisor chat")
.expect("autonomous supervisor chat command");
assert_eq!(
command,
CliCommand::SwarmChat {
project_path,
parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
initialize: false,
run_profile: AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD.to_string(),
supervisor_source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
}
);
}
#[test]
fn swarm_chat_rejects_missing_or_extra_arguments() {
assert!(parse_cli_command(&["--swarm-chat".to_string()]).is_err());
assert!(parse_cli_command(&[
"--swarm-chat".to_string(),
"/tmp/game-project".to_string(),
"code-prototype".to_string(),
"extra".to_string(),
])
.is_err());
assert!(parse_cli_command(&[
"--swarm-chat".to_string(),
"--autonomous-game-build".to_string(),
"--autonomous-game-build".to_string(),
"/tmp/game-project".to_string(),
])
.is_err());
}
}