grok-build-upstream-mirror/crates/codegen/xai-grok-pager/src/headless/cli.rs

249 lines
8.5 KiB
Rust
Raw Normal View History

Synced from monorepo Synced from monorepo Changes: - Temporarily disable session share link creation in the TUI - Do not approve plan on empty Enter from the revise prompt - Expose chat product Skills via ACP available_commands_update - Return immediately from a blocking wait on an already-completed ACP task - Split headless pager module for clearer structure - Stop git worktree prune from removing user registrations on resume - Use compaction sampler tokenizer for item token counts - Opt-in extra root CAs via GROK_EXTRA_CA_BUNDLE - Cancel all session subagents when the user stops - Let the session persistence actor exit when its session ends - Make fullscreen terminal resize much cheaper on long sessions - Report honestly from kill_task when an ACP task does not exist - Hide /usage for external-auth deployments - Forward the history-load trailer’s computer_reason to the client - Remove ineffective no-op tool reminder - Declare slash-command screen-mode support in one place - Keep settings enum picker on the committed value until Enter - Reap a PTY’s full process tree - Stream tool calls from headless mode over ACP - Bridge gateway task lifecycle to ACP for chat session background tasks - Don’t warn about truncated history on a suppressed replay - Fit full-replace summarizer input and recover on context-length errors - Stop dropping agents over an unrecognized frontmatter color - Add /undo as a slash alias for /rewind - Harden sleep/wake token-refresh paths against forced re-login - Add session/list ACP method - Give each sampling backend its own conversion module - Treat an unenrolled child process as a lint error - Suppress the cancelled marker on send-now wake turns - Stop tearing down Roslyn on every edit, and read C# diagnostics Source-Revision: 2a28b4a86cfc4a4c133c35b7fc2a6a9964387c39
2026-07-30 19:07:40 +00:00
//! Headless CLI parsing: output format, prompt sources, permission rules, and agent args.
use std::path::{Path, PathBuf};
use agent_client_protocol as acp;
use clap::ValueEnum;
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
#[default]
Plain,
Json,
/// NDJSON of the agent native ACP session updates.
#[value(name = "streaming-json")]
StreamingJson,
/// NDJSON in the Anthropic Messages API wire format.
#[value(name = "streaming-messages-json")]
StreamingMessagesJson,
}
pub fn parse_json_schema(input: &str) -> anyhow::Result<serde_json::Value> {
let schema: serde_json::Value = serde_json::from_str(input)
.map_err(|e| anyhow::anyhow!("--json-schema: invalid JSON: {e}"))?;
if !schema.is_object() {
anyhow::bail!("--json-schema: must be a JSON object describing a JSON Schema");
}
Ok(schema)
}
#[derive(Debug, Clone)]
pub enum HeadlessPrompt {
Text(String),
Blocks(Vec<acp::ContentBlock>),
}
impl HeadlessPrompt {
/// Build from mutually-exclusive CLI prompt args. `None` = interactive mode.
pub fn from_args(
single: Option<&str>,
prompt_json: Option<&str>,
prompt_file: Option<&Path>,
) -> anyhow::Result<Option<Self>> {
if let Some(text) = single {
Self::from_text(text)
.map(Some)
.map_err(|e| anyhow::anyhow!("--single: {e}"))
} else if let Some(json_str) = prompt_json {
Self::from_json(json_str)
.map(Some)
.map_err(|e| anyhow::anyhow!("--prompt-json: {e}"))
} else if let Some(path) = prompt_file {
Self::from_file(path).map(Some)
} else {
Ok(None)
}
}
/// `.json` files are parsed as content blocks, everything else as text.
pub fn from_file(path: &Path) -> anyhow::Result<Self> {
let content = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read '{}': {e}", path.display()))?;
let context = |e| anyhow::anyhow!("'{}': {e}", path.display());
if path.extension().and_then(|e| e.to_str()) == Some("json") {
Self::from_json(&content).map_err(context)
} else {
Self::from_text(&content).map_err(context)
}
}
fn from_text(text: &str) -> anyhow::Result<Self> {
let trimmed = text.trim();
if trimmed.is_empty() {
anyhow::bail!("prompt is empty");
}
Ok(Self::Text(trimmed.to_string()))
}
fn from_json(json_str: &str) -> anyhow::Result<Self> {
let blocks = parse_prompt_json(json_str)?;
Ok(Self::Blocks(blocks))
}
pub fn into_content_blocks(self) -> Vec<acp::ContentBlock> {
match self {
Self::Text(text) => vec![acp::ContentBlock::Text(acp::TextContent::new(text))],
Self::Blocks(blocks) => blocks,
}
}
}
/// Parse ACP content blocks from an array (`[...]`) or typed wrapper (`{"type":"acp","content":[...]}`).
fn parse_prompt_json(json_str: &str) -> anyhow::Result<Vec<acp::ContentBlock>> {
let value: serde_json::Value =
serde_json::from_str(json_str).map_err(|e| anyhow::anyhow!("Invalid JSON: {e}"))?;
let blocks: Vec<acp::ContentBlock> = match value {
serde_json::Value::Array(_) => serde_json::from_value(value)
.map_err(|e| anyhow::anyhow!("Invalid ACP content blocks: {e}"))?,
serde_json::Value::Object(ref map) => {
let format_type = map.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
anyhow::anyhow!(
"JSON object must have a \"type\" field \
(e.g., {{\"type\": \"acp\", \"content\": [...]}})"
)
})?;
let content = map
.get("content")
.ok_or_else(|| anyhow::anyhow!("JSON object must have a \"content\" field"))?;
match format_type {
"acp" => serde_json::from_value(content.clone()).map_err(|e| {
anyhow::anyhow!("Invalid ACP content blocks in \"content\": {e}")
})?,
other => anyhow::bail!(
"Unsupported prompt format type: \"{other}\". Supported types: \"acp\""
),
}
}
_ => {
anyhow::bail!("Expected JSON array or {{\"type\": \"...\", \"content\": [...]}} object")
}
};
if blocks.is_empty() {
anyhow::bail!("content blocks array is empty");
}
Ok(blocks)
}
/// Parse a comma-separated list into a vec, or None if empty.
pub(crate) fn parse_comma_list(s: Option<&str>) -> Option<Vec<String>> {
s.and_then(|s| {
let v: Vec<String> = s
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
if v.is_empty() { None } else { Some(v) }
})
}
pub fn parse_permission_rules_strict(
allow: &[String],
deny: &[String],
) -> anyhow::Result<Vec<xai_grok_workspace::permission::types::PermissionRule>> {
let (rules, errors) = parse_permission_rules_inner(allow, deny);
if !errors.is_empty() {
let msgs: Vec<String> = errors
.into_iter()
.map(|(flag, rule, err)| format!("{flag} \"{rule}\": {err}"))
.collect();
anyhow::bail!("{}", msgs.join("; "));
}
Ok(rules)
}
pub fn parse_permission_rules_lenient(
allow: &[String],
deny: &[String],
) -> Vec<xai_grok_workspace::permission::types::PermissionRule> {
let (rules, errors) = parse_permission_rules_inner(allow, deny);
for (flag, rule, err) in errors {
eprintln!("warning: {flag} \"{rule}\": {err}, skipping");
}
rules
}
// Deny before allow is cosmetic: the policy evaluator is order-independent (deny > ask > allow).
pub(crate) fn parse_permission_rules_inner(
allow: &[String],
deny: &[String],
) -> (
Vec<xai_grok_workspace::permission::types::PermissionRule>,
Vec<(&'static str, String, String)>,
) {
use xai_grok_workspace::permission::rules::parse_permission_rule;
use xai_grok_workspace::permission::types::RuleAction;
let mut rules = Vec::new();
let mut errors = Vec::new();
for rule_str in deny {
match parse_permission_rule(rule_str, RuleAction::Deny) {
Ok(rule) => rules.push(rule),
Err(e) => errors.push(("--deny", rule_str.clone(), e.to_string())),
}
}
for rule_str in allow {
match parse_permission_rule(rule_str, RuleAction::Allow) {
Ok(rule) => rules.push(rule),
Err(e) => errors.push(("--allow", rule_str.clone(), e.to_string())),
}
}
(rules, errors)
}
pub(crate) enum ResolvedAgent {
FilePath(PathBuf),
Name(String),
}
pub(crate) fn resolve_agent_arg(agent: &str) -> ResolvedAgent {
let path = std::path::Path::new(agent);
if path.exists() && path.is_file() {
ResolvedAgent::FilePath(dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()))
} else {
ResolvedAgent::Name(agent.to_string())
}
}
pub(crate) fn parse_cli_agents(
json: &str,
) -> anyhow::Result<Vec<xai_grok_shell::agent::config::AgentDefinition>> {
let map: std::collections::HashMap<String, serde_json::Value> =
serde_json::from_str(json).map_err(|e| anyhow::anyhow!("--agents: invalid JSON: {e}"))?;
let mut agents = Vec::with_capacity(map.len());
for (name, mut value) in map {
if let serde_json::Value::Object(ref mut obj) = value {
if !obj.contains_key("promptBody")
&& let Some(prompt) = obj.remove("prompt")
{
obj.insert("promptBody".to_string(), prompt);
}
obj.entry("name".to_string())
.or_insert_with(|| serde_json::Value::String(name.clone()));
obj.entry("description".to_string())
.or_insert_with(|| serde_json::Value::String(name.clone()));
}
let mut def = xai_grok_shell::agent::config::AgentDefinition::from_json(&value)
.map_err(|e| anyhow::anyhow!("--agents: failed to parse '{name}': {e}"))?;
def.name = name;
agents.push(def);
}
Ok(agents)
}
pub(crate) fn apply_agent_flag(
agent: &Option<String>,
config: &mut xai_grok_shell::agent::config::Config,
) {
if let Some(agent) = agent {
match resolve_agent_arg(agent) {
ResolvedAgent::FilePath(path) => config.agent_profile_path = Some(path),
ResolvedAgent::Name(name) => config.agent.name = Some(name),
}
}
}