Compare commits

..

1 Commits

Author SHA1 Message Date
Yeachan-Heo
00b557c8b7 Enforce machine-readable output across CLI surfaces 2026-04-05 17:35:36 +00:00
3 changed files with 803 additions and 168 deletions

View File

@@ -1,68 +0,0 @@
name: Release binaries
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: build-${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- name: linux-x64
os: ubuntu-latest
bin: claw
artifact_name: claw-linux-x64
- name: macos-arm64
os: macos-14
bin: claw
artifact_name: claw-macos-arm64
defaults:
run:
working-directory: rust
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust -> target
- name: Build release binary
run: cargo build --release -p rusty-claude-cli
- name: Package artifact
shell: bash
run: |
mkdir -p dist
cp "target/release/${{ matrix.bin }}" "dist/${{ matrix.artifact_name }}"
chmod +x "dist/${{ matrix.artifact_name }}"
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: rust/dist/${{ matrix.artifact_name }}
- name: Upload release asset
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: rust/dist/${{ matrix.artifact_name }}
fail_on_unmatched_files: true

View File

@@ -107,13 +107,26 @@ Run `claw --help` for usage."
fn run() -> Result<(), Box<dyn std::error::Error>> { fn run() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().skip(1).collect(); let args: Vec<String> = env::args().skip(1).collect();
match parse_args(&args)? { match parse_args(&args)? {
CliAction::DumpManifests => dump_manifests(), CliAction::DumpManifests { output_format } => dump_manifests(output_format),
CliAction::BootstrapPlan => print_bootstrap_plan(), CliAction::BootstrapPlan { output_format } => print_bootstrap_plan(output_format)?,
CliAction::Agents { args } => LiveCli::print_agents(args.as_deref())?, CliAction::Agents {
CliAction::Mcp { args } => LiveCli::print_mcp(args.as_deref())?, args,
CliAction::Skills { args } => LiveCli::print_skills(args.as_deref())?, output_format,
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date), } => LiveCli::print_agents(args.as_deref(), output_format)?,
CliAction::Version => print_version(), CliAction::Mcp {
args,
output_format,
} => LiveCli::print_mcp(args.as_deref(), output_format)?,
CliAction::Skills {
args,
output_format,
} => LiveCli::print_skills(args.as_deref(), output_format)?,
CliAction::PrintSystemPrompt {
cwd,
date,
output_format,
} => print_system_prompt(cwd, date, output_format)?,
CliAction::Version { output_format } => print_version(output_format)?,
CliAction::ResumeSession { CliAction::ResumeSession {
session_path, session_path,
commands, commands,
@@ -133,37 +146,47 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
permission_mode, permission_mode,
} => LiveCli::new(model, true, allowed_tools, permission_mode)? } => LiveCli::new(model, true, allowed_tools, permission_mode)?
.run_turn_with_output(&prompt, output_format)?, .run_turn_with_output(&prompt, output_format)?,
CliAction::Login => run_login()?, CliAction::Login { output_format } => run_login(output_format)?,
CliAction::Logout => run_logout()?, CliAction::Logout { output_format } => run_logout(output_format)?,
CliAction::Init => run_init()?, CliAction::Init { output_format } => run_init(output_format)?,
CliAction::Repl { CliAction::Repl {
model, model,
allowed_tools, allowed_tools,
permission_mode, permission_mode,
} => run_repl(model, allowed_tools, permission_mode)?, } => run_repl(model, allowed_tools, permission_mode)?,
CliAction::Help => print_help(), CliAction::Help { output_format } => print_help(output_format)?,
} }
Ok(()) Ok(())
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
enum CliAction { enum CliAction {
DumpManifests, DumpManifests {
BootstrapPlan, output_format: CliOutputFormat,
},
BootstrapPlan {
output_format: CliOutputFormat,
},
Agents { Agents {
args: Option<String>, args: Option<String>,
output_format: CliOutputFormat,
}, },
Mcp { Mcp {
args: Option<String>, args: Option<String>,
output_format: CliOutputFormat,
}, },
Skills { Skills {
args: Option<String>, args: Option<String>,
output_format: CliOutputFormat,
}, },
PrintSystemPrompt { PrintSystemPrompt {
cwd: PathBuf, cwd: PathBuf,
date: String, date: String,
output_format: CliOutputFormat,
},
Version {
output_format: CliOutputFormat,
}, },
Version,
ResumeSession { ResumeSession {
session_path: PathBuf, session_path: PathBuf,
commands: Vec<String>, commands: Vec<String>,
@@ -184,16 +207,24 @@ enum CliAction {
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode, permission_mode: PermissionMode,
}, },
Login, Login {
Logout, output_format: CliOutputFormat,
Init, },
Logout {
output_format: CliOutputFormat,
},
Init {
output_format: CliOutputFormat,
},
Repl { Repl {
model: String, model: String,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode, permission_mode: PermissionMode,
}, },
// prompt-mode formatting is only supported for non-interactive runs // prompt-mode formatting is only supported for non-interactive runs
Help, Help {
output_format: CliOutputFormat,
},
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -327,11 +358,11 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
} }
if wants_help { if wants_help {
return Ok(CliAction::Help); return Ok(CliAction::Help { output_format });
} }
if wants_version { if wants_version {
return Ok(CliAction::Version); return Ok(CliAction::Version { output_format });
} }
let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?; let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
@@ -356,21 +387,24 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode);
match rest[0].as_str() { match rest[0].as_str() {
"dump-manifests" => Ok(CliAction::DumpManifests), "dump-manifests" => Ok(CliAction::DumpManifests { output_format }),
"bootstrap-plan" => Ok(CliAction::BootstrapPlan), "bootstrap-plan" => Ok(CliAction::BootstrapPlan { output_format }),
"agents" => Ok(CliAction::Agents { "agents" => Ok(CliAction::Agents {
args: join_optional_args(&rest[1..]), args: join_optional_args(&rest[1..]),
output_format,
}), }),
"mcp" => Ok(CliAction::Mcp { "mcp" => Ok(CliAction::Mcp {
args: join_optional_args(&rest[1..]), args: join_optional_args(&rest[1..]),
output_format,
}), }),
"skills" => Ok(CliAction::Skills { "skills" => Ok(CliAction::Skills {
args: join_optional_args(&rest[1..]), args: join_optional_args(&rest[1..]),
output_format,
}), }),
"system-prompt" => parse_system_prompt_args(&rest[1..]), "system-prompt" => parse_system_prompt_args(&rest[1..], output_format),
"login" => Ok(CliAction::Login), "login" => Ok(CliAction::Login { output_format }),
"logout" => Ok(CliAction::Logout), "logout" => Ok(CliAction::Logout { output_format }),
"init" => Ok(CliAction::Init), "init" => Ok(CliAction::Init { output_format }),
"prompt" => { "prompt" => {
let prompt = rest[1..].join(" "); let prompt = rest[1..].join(" ");
if prompt.trim().is_empty() { if prompt.trim().is_empty() {
@@ -384,7 +418,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
permission_mode, permission_mode,
}) })
} }
other if other.starts_with('/') => parse_direct_slash_cli_action(&rest), other if other.starts_with('/') => parse_direct_slash_cli_action(&rest, output_format),
_other => Ok(CliAction::Prompt { _other => Ok(CliAction::Prompt {
prompt: rest.join(" "), prompt: rest.join(" "),
model, model,
@@ -406,8 +440,8 @@ fn parse_single_word_command_alias(
} }
match rest[0].as_str() { match rest[0].as_str() {
"help" => Some(Ok(CliAction::Help)), "help" => Some(Ok(CliAction::Help { output_format })),
"version" => Some(Ok(CliAction::Version)), "version" => Some(Ok(CliAction::Version { output_format })),
"status" => Some(Ok(CliAction::Status { "status" => Some(Ok(CliAction::Status {
model: model.to_string(), model: model.to_string(),
permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode), permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode),
@@ -455,11 +489,17 @@ fn join_optional_args(args: &[String]) -> Option<String> {
(!trimmed.is_empty()).then(|| trimmed.to_string()) (!trimmed.is_empty()).then(|| trimmed.to_string())
} }
fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> { fn parse_direct_slash_cli_action(
rest: &[String],
output_format: CliOutputFormat,
) -> Result<CliAction, String> {
let raw = rest.join(" "); let raw = rest.join(" ");
match SlashCommand::parse(&raw) { match SlashCommand::parse(&raw) {
Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help), Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help { output_format }),
Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { args }), Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents {
args,
output_format,
}),
Ok(Some(SlashCommand::Mcp { action, target })) => Ok(CliAction::Mcp { Ok(Some(SlashCommand::Mcp { action, target })) => Ok(CliAction::Mcp {
args: match (action, target) { args: match (action, target) {
(None, None) => None, (None, None) => None,
@@ -467,8 +507,12 @@ fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
(Some(action), Some(target)) => Some(format!("{action} {target}")), (Some(action), Some(target)) => Some(format!("{action} {target}")),
(None, Some(target)) => Some(target), (None, Some(target)) => Some(target),
}, },
output_format,
}),
Ok(Some(SlashCommand::Skills { args })) => Ok(CliAction::Skills {
args,
output_format,
}), }),
Ok(Some(SlashCommand::Skills { args })) => Ok(CliAction::Skills { args }),
Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_direct_slash_command(&name)), Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_direct_slash_command(&name)),
Ok(Some(command)) => Err({ Ok(Some(command)) => Err({
let _ = command; let _ = command;
@@ -679,7 +723,10 @@ fn filter_tool_specs(
tool_registry.definitions(allowed_tools) tool_registry.definitions(allowed_tools)
} }
fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> { fn parse_system_prompt_args(
args: &[String],
output_format: CliOutputFormat,
) -> Result<CliAction, String> {
let mut cwd = env::current_dir().map_err(|error| error.to_string())?; let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
let mut date = DEFAULT_DATE.to_string(); let mut date = DEFAULT_DATE.to_string();
let mut index = 0; let mut index = 0;
@@ -704,7 +751,11 @@ fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
} }
} }
Ok(CliAction::PrintSystemPrompt { cwd, date }) Ok(CliAction::PrintSystemPrompt {
cwd,
date,
output_format,
})
} }
fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result<CliAction, String> { fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result<CliAction, String> {
@@ -775,25 +826,73 @@ fn looks_like_slash_command_token(token: &str) -> bool {
.any(|spec| spec.name == name || spec.aliases.contains(&name)) .any(|spec| spec.name == name || spec.aliases.contains(&name))
} }
fn dump_manifests() { fn print_json_value(value: &Value) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", serialize_json_output(value)?);
Ok(())
}
fn json_error_payload(kind: &str, error: &dyn std::fmt::Display) -> Value {
json!({
"kind": kind,
"error": error.to_string(),
})
}
fn render_help_report() -> io::Result<String> {
let mut buffer = Vec::new();
print_help_to(&mut buffer)?;
String::from_utf8(buffer)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.utf8_error()))
}
fn dump_manifests(output_format: CliOutputFormat) {
let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let paths = UpstreamPaths::from_workspace_dir(&workspace_dir); let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
match extract_manifest(&paths) { match extract_manifest(&paths) {
Ok(manifest) => { Ok(manifest) => match output_format {
println!("commands: {}", manifest.commands.entries().len()); CliOutputFormat::Text => {
println!("tools: {}", manifest.tools.entries().len()); println!("commands: {}", manifest.commands.entries().len());
println!("bootstrap phases: {}", manifest.bootstrap.phases().len()); println!("tools: {}", manifest.tools.entries().len());
} println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
}
CliOutputFormat::Json => {
let _ = print_json_value(&json!({
"kind": "dump-manifests",
"commands": manifest.commands.entries().len(),
"tools": manifest.tools.entries().len(),
"bootstrap_phases": manifest.bootstrap.phases().len(),
}));
}
},
Err(error) => { Err(error) => {
eprintln!("failed to extract manifests: {error}"); match output_format {
CliOutputFormat::Text => eprintln!("failed to extract manifests: {error}"),
CliOutputFormat::Json => {
let _ = print_json_value(&json_error_payload("dump-manifests", &error));
}
}
std::process::exit(1); std::process::exit(1);
} }
} }
} }
fn print_bootstrap_plan() { fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
for phase in runtime::BootstrapPlan::claude_code_default().phases() { let phases = runtime::BootstrapPlan::claude_code_default()
println!("- {phase:?}"); .phases()
.iter()
.map(|phase| format!("{phase:?}"))
.collect::<Vec<_>>();
match output_format {
CliOutputFormat::Text => {
for phase in &phases {
println!("- {phase}");
}
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "bootstrap-plan",
"phases": phases,
})),
} }
} }
@@ -812,7 +911,7 @@ fn default_oauth_config() -> OAuthConfig {
} }
} }
fn run_login() -> Result<(), Box<dyn std::error::Error>> { fn run_login(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
let config = ConfigLoader::default_for(&cwd).load()?; let config = ConfigLoader::default_for(&cwd).load()?;
let default_oauth = default_oauth_config(); let default_oauth = default_oauth_config();
@@ -825,11 +924,20 @@ fn run_login() -> Result<(), Box<dyn std::error::Error>> {
OAuthAuthorizationRequest::from_config(oauth, redirect_uri.clone(), state.clone(), &pkce) OAuthAuthorizationRequest::from_config(oauth, redirect_uri.clone(), state.clone(), &pkce)
.build_url(); .build_url();
println!("Starting Claude OAuth login..."); if matches!(output_format, CliOutputFormat::Text) {
println!("Listening for callback on {redirect_uri}"); println!("Starting Claude OAuth login...");
println!("Listening for callback on {redirect_uri}");
}
if let Err(error) = open_browser(&authorize_url) { if let Err(error) = open_browser(&authorize_url) {
eprintln!("warning: failed to open browser automatically: {error}"); if matches!(output_format, CliOutputFormat::Text) {
println!("Open this URL manually:\n{authorize_url}"); eprintln!("warning: failed to open browser automatically: {error}");
println!("Open this URL manually:\n{authorize_url}");
} else {
return Err(io::Error::other(format!(
"failed to open browser automatically: {error}; authorization URL: {authorize_url}"
))
.into());
}
} }
let callback = wait_for_oauth_callback(callback_port)?; let callback = wait_for_oauth_callback(callback_port)?;
@@ -850,8 +958,13 @@ fn run_login() -> Result<(), Box<dyn std::error::Error>> {
} }
let client = AnthropicClient::from_auth(AuthSource::None).with_base_url(api::read_base_url()); let client = AnthropicClient::from_auth(AuthSource::None).with_base_url(api::read_base_url());
let exchange_request = let exchange_request = OAuthTokenExchangeRequest::from_config(
OAuthTokenExchangeRequest::from_config(oauth, code, state, pkce.verifier, redirect_uri); oauth,
code,
state,
pkce.verifier,
redirect_uri.clone(),
);
let runtime = tokio::runtime::Runtime::new()?; let runtime = tokio::runtime::Runtime::new()?;
let token_set = runtime.block_on(client.exchange_oauth_code(oauth, &exchange_request))?; let token_set = runtime.block_on(client.exchange_oauth_code(oauth, &exchange_request))?;
save_oauth_credentials(&runtime::OAuthTokenSet { save_oauth_credentials(&runtime::OAuthTokenSet {
@@ -860,14 +973,33 @@ fn run_login() -> Result<(), Box<dyn std::error::Error>> {
expires_at: token_set.expires_at, expires_at: token_set.expires_at,
scopes: token_set.scopes, scopes: token_set.scopes,
})?; })?;
println!("Claude OAuth login complete."); match output_format {
Ok(()) CliOutputFormat::Text => {
println!("Claude OAuth login complete.");
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "login",
"message": "Claude OAuth login complete.",
"authorize_url": authorize_url,
"redirect_uri": redirect_uri,
"callback_port": callback_port,
})),
}
} }
fn run_logout() -> Result<(), Box<dyn std::error::Error>> { fn run_logout(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
clear_oauth_credentials()?; clear_oauth_credentials()?;
println!("Claude OAuth credentials cleared."); match output_format {
Ok(()) CliOutputFormat::Text => {
println!("Claude OAuth credentials cleared.");
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "logout",
"message": "Claude OAuth credentials cleared.",
})),
}
} }
fn open_browser(url: &str) -> io::Result<()> { fn open_browser(url: &str) -> io::Result<()> {
@@ -924,18 +1056,49 @@ fn wait_for_oauth_callback(
Ok(callback) Ok(callback)
} }
fn print_system_prompt(cwd: PathBuf, date: String) { fn print_system_prompt(
cwd: PathBuf,
date: String,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match load_system_prompt(cwd, date, env::consts::OS, "unknown") { match load_system_prompt(cwd, date, env::consts::OS, "unknown") {
Ok(sections) => println!("{}", sections.join("\n\n")), Ok(sections) => match output_format {
CliOutputFormat::Text => {
println!("{}", sections.join("\n\n"));
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "system-prompt",
"message": sections.join("\n\n"),
})),
},
Err(error) => { Err(error) => {
eprintln!("failed to build system prompt: {error}"); match output_format {
CliOutputFormat::Text => eprintln!("failed to build system prompt: {error}"),
CliOutputFormat::Json => {
print_json_value(&json_error_payload("system-prompt", &error))?;
}
}
std::process::exit(1); std::process::exit(1);
} }
} }
} }
fn print_version() { fn print_version(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_version_report()); match output_format {
CliOutputFormat::Text => {
println!("{}", render_version_report());
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "version",
"message": render_version_report(),
"version": VERSION,
"git_sha": GIT_SHA.unwrap_or("unknown"),
"target": BUILD_TARGET.unwrap_or("unknown"),
"build_date": DEFAULT_DATE,
})),
}
} }
fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) { fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) {
@@ -1006,7 +1169,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
if let Some(message) = message { if let Some(message) = message {
match output_format { match output_format {
CliOutputFormat::Text => { CliOutputFormat::Text => {
println!("{}", render_resume_text_output(&message)) println!("{}", render_resume_text_output(&message));
} }
CliOutputFormat::Json => json_outputs.push(message), CliOutputFormat::Json => json_outputs.push(message),
} }
@@ -2467,7 +2630,7 @@ impl LiveCli {
(Some(action), Some(target)) => Some(format!("{action} {target}")), (Some(action), Some(target)) => Some(format!("{action} {target}")),
(None, Some(target)) => Some(target.to_string()), (None, Some(target)) => Some(target.to_string()),
}; };
Self::print_mcp(args.as_deref())?; Self::print_mcp(args.as_deref(), CliOutputFormat::Text)?;
false false
} }
SlashCommand::Memory => { SlashCommand::Memory => {
@@ -2475,7 +2638,7 @@ impl LiveCli {
false false
} }
SlashCommand::Init => { SlashCommand::Init => {
run_init()?; run_init(CliOutputFormat::Text)?;
false false
} }
SlashCommand::Diff => { SlashCommand::Diff => {
@@ -2483,7 +2646,7 @@ impl LiveCli {
false false
} }
SlashCommand::Version => { SlashCommand::Version => {
Self::print_version(); Self::print_version()?;
false false
} }
SlashCommand::Export { path } => { SlashCommand::Export { path } => {
@@ -2497,11 +2660,11 @@ impl LiveCli {
self.handle_plugins_command(action.as_deref(), target.as_deref())? self.handle_plugins_command(action.as_deref(), target.as_deref())?
} }
SlashCommand::Agents { args } => { SlashCommand::Agents { args } => {
Self::print_agents(args.as_deref())?; Self::print_agents(args.as_deref(), CliOutputFormat::Text)?;
false false
} }
SlashCommand::Skills { args } => { SlashCommand::Skills { args } => {
Self::print_skills(args.as_deref())?; Self::print_skills(args.as_deref(), CliOutputFormat::Text)?;
false false
} }
SlashCommand::Doctor SlashCommand::Doctor
@@ -2776,22 +2939,61 @@ impl LiveCli {
Ok(()) Ok(())
} }
fn print_agents(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> { fn print_agents(
args: Option<&str>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
println!("{}", handle_agents_slash_command(args, &cwd)?); let message = handle_agents_slash_command(args, &cwd)?;
Ok(()) match output_format {
CliOutputFormat::Text => {
println!("{message}");
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "agents",
"message": message,
"args": args,
})),
}
} }
fn print_mcp(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> { fn print_mcp(
args: Option<&str>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
println!("{}", handle_mcp_slash_command(args, &cwd)?); let message = handle_mcp_slash_command(args, &cwd)?;
Ok(()) match output_format {
CliOutputFormat::Text => {
println!("{message}");
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "mcp",
"message": message,
"args": args,
})),
}
} }
fn print_skills(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> { fn print_skills(
args: Option<&str>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
println!("{}", handle_skills_slash_command(args, &cwd)?); let message = handle_skills_slash_command(args, &cwd)?;
Ok(()) match output_format {
CliOutputFormat::Text => {
println!("{message}");
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "skills",
"message": message,
"args": args,
})),
}
} }
fn print_diff() -> Result<(), Box<dyn std::error::Error>> { fn print_diff() -> Result<(), Box<dyn std::error::Error>> {
@@ -2799,8 +3001,8 @@ impl LiveCli {
Ok(()) Ok(())
} }
fn print_version() { fn print_version() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_version_report()); crate::print_version(CliOutputFormat::Text)
} }
fn export_session( fn export_session(
@@ -3709,9 +3911,18 @@ fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
Ok(initialize_repo(&cwd)?.render()) Ok(initialize_repo(&cwd)?.render())
} }
fn run_init() -> Result<(), Box<dyn std::error::Error>> { fn run_init(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", init_claude_md()?); let init_report = init_claude_md()?;
Ok(()) match output_format {
CliOutputFormat::Text => {
println!("{init_report}");
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "init",
"message": init_report,
})),
}
} }
fn normalize_permission_mode(mode: &str) -> Option<&'static str> { fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
@@ -5846,8 +6057,17 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
Ok(()) Ok(())
} }
fn print_help() { fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let _ = print_help_to(&mut io::stdout()); match output_format {
CliOutputFormat::Text => {
print_help_to(&mut io::stdout())?;
Ok(())
}
CliOutputFormat::Json => print_json_value(&json!({
"kind": "help",
"message": render_help_report()?,
})),
}
} }
#[cfg(test)] #[cfg(test)]
@@ -6156,11 +6376,15 @@ mod tests {
fn parses_version_flags_without_initializing_prompt_mode() { fn parses_version_flags_without_initializing_prompt_mode() {
assert_eq!( assert_eq!(
parse_args(&["--version".to_string()]).expect("args should parse"), parse_args(&["--version".to_string()]).expect("args should parse"),
CliAction::Version CliAction::Version {
output_format: CliOutputFormat::Text,
}
); );
assert_eq!( assert_eq!(
parse_args(&["-V".to_string()]).expect("args should parse"), parse_args(&["-V".to_string()]).expect("args should parse"),
CliAction::Version CliAction::Version {
output_format: CliOutputFormat::Text,
}
); );
} }
@@ -6222,6 +6446,7 @@ mod tests {
CliAction::PrintSystemPrompt { CliAction::PrintSystemPrompt {
cwd: PathBuf::from("/tmp/project"), cwd: PathBuf::from("/tmp/project"),
date: "2026-04-01".to_string(), date: "2026-04-01".to_string(),
output_format: CliOutputFormat::Text,
} }
); );
} }
@@ -6230,33 +6455,49 @@ mod tests {
fn parses_login_and_logout_subcommands() { fn parses_login_and_logout_subcommands() {
assert_eq!( assert_eq!(
parse_args(&["login".to_string()]).expect("login should parse"), parse_args(&["login".to_string()]).expect("login should parse"),
CliAction::Login CliAction::Login {
output_format: CliOutputFormat::Text,
}
); );
assert_eq!( assert_eq!(
parse_args(&["logout".to_string()]).expect("logout should parse"), parse_args(&["logout".to_string()]).expect("logout should parse"),
CliAction::Logout CliAction::Logout {
output_format: CliOutputFormat::Text,
}
); );
assert_eq!( assert_eq!(
parse_args(&["init".to_string()]).expect("init should parse"), parse_args(&["init".to_string()]).expect("init should parse"),
CliAction::Init CliAction::Init {
output_format: CliOutputFormat::Text,
}
); );
assert_eq!( assert_eq!(
parse_args(&["agents".to_string()]).expect("agents should parse"), parse_args(&["agents".to_string()]).expect("agents should parse"),
CliAction::Agents { args: None } CliAction::Agents {
args: None,
output_format: CliOutputFormat::Text,
}
); );
assert_eq!( assert_eq!(
parse_args(&["mcp".to_string()]).expect("mcp should parse"), parse_args(&["mcp".to_string()]).expect("mcp should parse"),
CliAction::Mcp { args: None } CliAction::Mcp {
args: None,
output_format: CliOutputFormat::Text,
}
); );
assert_eq!( assert_eq!(
parse_args(&["skills".to_string()]).expect("skills should parse"), parse_args(&["skills".to_string()]).expect("skills should parse"),
CliAction::Skills { args: None } CliAction::Skills {
args: None,
output_format: CliOutputFormat::Text,
}
); );
assert_eq!( assert_eq!(
parse_args(&["agents".to_string(), "--help".to_string()]) parse_args(&["agents".to_string(), "--help".to_string()])
.expect("agents help should parse"), .expect("agents help should parse"),
CliAction::Agents { CliAction::Agents {
args: Some("--help".to_string()) args: Some("--help".to_string()),
output_format: CliOutputFormat::Text,
} }
); );
} }
@@ -6267,11 +6508,15 @@ mod tests {
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
assert_eq!( assert_eq!(
parse_args(&["help".to_string()]).expect("help should parse"), parse_args(&["help".to_string()]).expect("help should parse"),
CliAction::Help CliAction::Help {
output_format: CliOutputFormat::Text,
}
); );
assert_eq!( assert_eq!(
parse_args(&["version".to_string()]).expect("version should parse"), parse_args(&["version".to_string()]).expect("version should parse"),
CliAction::Version CliAction::Version {
output_format: CliOutputFormat::Text,
}
); );
assert_eq!( assert_eq!(
parse_args(&["status".to_string()]).expect("status should parse"), parse_args(&["status".to_string()]).expect("status should parse"),
@@ -6339,24 +6584,32 @@ mod tests {
fn parses_direct_agents_mcp_and_skills_slash_commands() { fn parses_direct_agents_mcp_and_skills_slash_commands() {
assert_eq!( assert_eq!(
parse_args(&["/agents".to_string()]).expect("/agents should parse"), parse_args(&["/agents".to_string()]).expect("/agents should parse"),
CliAction::Agents { args: None } CliAction::Agents {
args: None,
output_format: CliOutputFormat::Text,
}
); );
assert_eq!( assert_eq!(
parse_args(&["/mcp".to_string(), "show".to_string(), "demo".to_string()]) parse_args(&["/mcp".to_string(), "show".to_string(), "demo".to_string()])
.expect("/mcp show demo should parse"), .expect("/mcp show demo should parse"),
CliAction::Mcp { CliAction::Mcp {
args: Some("show demo".to_string()) args: Some("show demo".to_string()),
output_format: CliOutputFormat::Text,
} }
); );
assert_eq!( assert_eq!(
parse_args(&["/skills".to_string()]).expect("/skills should parse"), parse_args(&["/skills".to_string()]).expect("/skills should parse"),
CliAction::Skills { args: None } CliAction::Skills {
args: None,
output_format: CliOutputFormat::Text,
}
); );
assert_eq!( assert_eq!(
parse_args(&["/skills".to_string(), "help".to_string()]) parse_args(&["/skills".to_string(), "help".to_string()])
.expect("/skills help should parse"), .expect("/skills help should parse"),
CliAction::Skills { CliAction::Skills {
args: Some("help".to_string()) args: Some("help".to_string()),
output_format: CliOutputFormat::Text,
} }
); );
assert_eq!( assert_eq!(
@@ -6367,7 +6620,8 @@ mod tests {
]) ])
.expect("/skills install should parse"), .expect("/skills install should parse"),
CliAction::Skills { CliAction::Skills {
args: Some("install ./fixtures/help-skill".to_string()) args: Some("install ./fixtures/help-skill".to_string()),
output_format: CliOutputFormat::Text,
} }
); );
let error = parse_args(&["/status".to_string()]) let error = parse_args(&["/status".to_string()])

View File

@@ -0,0 +1,449 @@
use std::fs;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
use serde_json::{json, Value};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[test]
fn help_emits_json_when_requested() {
let root = unique_temp_dir("help-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "help"], &envs);
assert_eq!(parsed["kind"], "help");
assert!(parsed["message"]
.as_str()
.expect("help message")
.contains("Usage:"));
}
#[test]
fn version_emits_json_when_requested() {
let root = unique_temp_dir("version-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "version"], &envs);
assert_eq!(parsed["kind"], "version");
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
}
#[test]
fn status_emits_json_when_requested() {
let root = unique_temp_dir("status-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "status"], &envs);
assert_eq!(parsed["kind"], "status");
assert!(parsed["workspace"]["cwd"].as_str().is_some());
}
#[test]
fn sandbox_emits_json_when_requested() {
let root = unique_temp_dir("sandbox-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "sandbox"], &envs);
assert_eq!(parsed["kind"], "sandbox");
assert!(parsed["sandbox"].is_object());
}
#[test]
fn dump_manifests_emits_json_when_requested() {
let root = unique_temp_dir("dump-manifests-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let upstream = write_upstream_fixture(&root);
let mut envs = isolated_env(&root);
envs.push((
"CLAUDE_CODE_UPSTREAM".to_string(),
upstream.display().to_string(),
));
let parsed = assert_json_command(&root, &["--output-format", "json", "dump-manifests"], &envs);
assert_eq!(parsed["kind"], "dump-manifests");
assert_eq!(parsed["commands"], 1);
assert_eq!(parsed["tools"], 1);
}
#[test]
fn bootstrap_plan_emits_json_when_requested() {
let root = unique_temp_dir("bootstrap-plan-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "bootstrap-plan"], &envs);
assert_eq!(parsed["kind"], "bootstrap-plan");
assert!(parsed["phases"].as_array().expect("phases array").len() > 1);
}
#[test]
fn agents_emits_json_when_requested() {
let root = unique_temp_dir("agents-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "agents"], &envs);
assert_eq!(parsed["kind"], "agents");
assert!(!parsed["message"].as_str().expect("agents text").is_empty());
}
#[test]
fn mcp_emits_json_when_requested() {
let root = unique_temp_dir("mcp-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "mcp"], &envs);
assert_eq!(parsed["kind"], "mcp");
assert!(parsed["message"]
.as_str()
.expect("mcp text")
.contains("MCP"));
}
#[test]
fn skills_emits_json_when_requested() {
let root = unique_temp_dir("skills-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "skills"], &envs);
assert_eq!(parsed["kind"], "skills");
assert!(!parsed["message"].as_str().expect("skills text").is_empty());
}
#[test]
fn system_prompt_emits_json_when_requested() {
let root = unique_temp_dir("system-prompt-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "system-prompt"], &envs);
assert_eq!(parsed["kind"], "system-prompt");
assert!(parsed["message"]
.as_str()
.expect("system prompt text")
.contains("You are an interactive agent"));
}
#[test]
fn login_emits_json_when_requested() {
let root = unique_temp_dir("login-json");
let workspace = root.join("workspace");
fs::create_dir_all(&workspace).expect("workspace should exist");
let mut envs = isolated_env(&root);
let callback_port = reserve_port();
let token_port = reserve_port();
fs::create_dir_all(workspace.join(".claw")).expect("config dir should exist");
fs::write(
workspace.join(".claw").join("settings.json"),
json!({
"oauth": {
"clientId": "test-client",
"authorizeUrl": format!("http://127.0.0.1:{token_port}/authorize"),
"tokenUrl": format!("http://127.0.0.1:{token_port}/token"),
"callbackPort": callback_port,
"scopes": ["user:test"]
}
})
.to_string(),
)
.expect("oauth config should write");
let token_server = thread::spawn(move || {
let listener = TcpListener::bind(("127.0.0.1", token_port)).expect("token server bind");
let (mut stream, _) = listener.accept().expect("token request");
let mut request = [0_u8; 4096];
let _ = stream
.read(&mut request)
.expect("token request should read");
let body = json!({
"access_token": "test-access-token",
"refresh_token": "test-refresh-token",
"expires_at": 9_999_999_999_u64,
"scopes": ["user:test"]
})
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("token response should write");
});
let bin_dir = root.join("bin");
fs::create_dir_all(&bin_dir).expect("bin dir should exist");
let opener_path = bin_dir.join("xdg-open");
fs::write(
&opener_path,
format!(
"#!/usr/bin/env python3\nimport http.client\nimport sys\nimport urllib.parse\nurl = sys.argv[1]\nquery = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)\nstate = query['state'][0]\nconn = http.client.HTTPConnection('127.0.0.1', {callback_port}, timeout=5)\nconn.request('GET', f\"/callback?code=test-code&state={{urllib.parse.quote(state)}}\")\nresp = conn.getresponse()\nresp.read()\nconn.close()\n"
),
)
.expect("xdg-open wrapper should write");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(&opener_path)
.expect("wrapper metadata")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&opener_path, permissions).expect("wrapper permissions");
}
let original_path = envs
.iter()
.find(|(key, _)| key == "PATH")
.map(|(_, value)| value.clone())
.unwrap_or_default();
for (key, value) in &mut envs {
if key == "PATH" {
*value = format!("{}:{original_path}", bin_dir.display());
}
}
let parsed = assert_json_command(&workspace, &["--output-format", "json", "login"], &envs);
token_server.join().expect("token server should finish");
assert_eq!(parsed["kind"], "login");
assert_eq!(parsed["callback_port"], callback_port);
}
#[test]
fn logout_emits_json_when_requested() {
let root = unique_temp_dir("logout-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&root, &["--output-format", "json", "logout"], &envs);
assert_eq!(parsed["kind"], "logout");
assert!(parsed["message"]
.as_str()
.expect("logout text")
.contains("cleared"));
}
#[test]
fn init_emits_json_when_requested() {
let root = unique_temp_dir("init-json");
let workspace = root.join("workspace");
fs::create_dir_all(&workspace).expect("workspace should exist");
let envs = isolated_env(&root);
let parsed = assert_json_command(&workspace, &["--output-format", "json", "init"], &envs);
assert_eq!(parsed["kind"], "init");
assert!(workspace.join("CLAUDE.md").exists());
}
#[test]
fn prompt_subcommand_emits_json_when_requested() {
let root = unique_temp_dir("prompt-subcommand-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let mut envs = isolated_env(&root);
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
let server = runtime
.block_on(MockAnthropicService::spawn())
.expect("mock service should start");
envs.push(("ANTHROPIC_API_KEY".to_string(), "test-key".to_string()));
envs.push(("ANTHROPIC_BASE_URL".to_string(), server.base_url()));
let prompt = format!("{SCENARIO_PREFIX}streaming_text");
let args = vec![
"--model".to_string(),
"sonnet".to_string(),
"--permission-mode".to_string(),
"read-only".to_string(),
"--output-format".to_string(),
"json".to_string(),
"prompt".to_string(),
prompt,
];
let output = run_claw_with_env_owned(&root, &args, &envs);
let parsed = parse_json_stdout(&output);
assert_eq!(parsed["model"], "claude-sonnet-4-6");
assert!(parsed["message"]
.as_str()
.expect("assistant text")
.contains("streaming"));
}
#[test]
fn bare_prompt_mode_emits_json_when_requested() {
let root = unique_temp_dir("bare-prompt-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let mut envs = isolated_env(&root);
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
let server = runtime
.block_on(MockAnthropicService::spawn())
.expect("mock service should start");
envs.push(("ANTHROPIC_API_KEY".to_string(), "test-key".to_string()));
envs.push(("ANTHROPIC_BASE_URL".to_string(), server.base_url()));
let prompt = format!("{SCENARIO_PREFIX}streaming_text");
let args = vec![
"--model".to_string(),
"sonnet".to_string(),
"--permission-mode".to_string(),
"read-only".to_string(),
"--output-format".to_string(),
"json".to_string(),
prompt,
];
let output = run_claw_with_env_owned(&root, &args, &envs);
let parsed = parse_json_stdout(&output);
assert_eq!(parsed["model"], "claude-sonnet-4-6");
assert!(parsed["message"]
.as_str()
.expect("assistant text")
.contains("streaming"));
}
#[test]
fn resume_restore_emits_json_when_requested() {
let root = unique_temp_dir("resume-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let envs = isolated_env(&root);
let session_path = root.join("session.jsonl");
fs::write(
&session_path,
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n{\"type\":\"message\",\"message\":{\"role\":\"user\",\"blocks\":[{\"type\":\"text\",\"text\":\"hello\"}]}}\n",
)
.expect("session should write");
let args = vec![
"--output-format".to_string(),
"json".to_string(),
"--resume".to_string(),
session_path.display().to_string(),
];
let output = run_claw_with_env_owned(&root, &args, &envs);
let parsed = parse_json_stdout(&output);
assert_eq!(parsed["kind"], "resume");
assert_eq!(parsed["messages"], 1);
}
fn assert_json_command(current_dir: &Path, args: &[&str], envs: &[(String, String)]) -> Value {
let output = run_claw_with_env(current_dir, args, envs);
parse_json_stdout(&output)
}
fn parse_json_stdout(output: &Output) -> Value {
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).expect("stdout should be json")
}
fn run_claw_with_env(current_dir: &Path, args: &[&str], envs: &[(String, String)]) -> Output {
let owned_args = args
.iter()
.map(|value| (*value).to_string())
.collect::<Vec<_>>();
run_claw_with_env_owned(current_dir, &owned_args, envs)
}
fn run_claw_with_env_owned(
current_dir: &Path,
args: &[String],
envs: &[(String, String)],
) -> Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
command.current_dir(current_dir).args(args).env_clear();
for (key, value) in envs {
command.env(key, value);
}
command.output().expect("claw should launch")
}
fn isolated_env(root: &Path) -> Vec<(String, String)> {
let config_home = root.join("config-home");
let home = root.join("home");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
vec![
(
"CLAW_CONFIG_HOME".to_string(),
config_home.display().to_string(),
),
("HOME".to_string(), home.display().to_string()),
(
"PATH".to_string(),
std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".to_string()),
),
("NO_COLOR".to_string(), "1".to_string()),
]
}
fn write_upstream_fixture(root: &Path) -> PathBuf {
let upstream = root.join("claw-code");
let src = upstream.join("src");
let entrypoints = src.join("entrypoints");
fs::create_dir_all(&entrypoints).expect("upstream entrypoints dir should exist");
fs::write(
src.join("commands.ts"),
"import FooCommand from './commands/foo'\n",
)
.expect("commands fixture should write");
fs::write(
src.join("tools.ts"),
"import ReadTool from './tools/read'\n",
)
.expect("tools fixture should write");
fs::write(
entrypoints.join("cli.tsx"),
"if (args[0] === '--version') {}\nstartupProfiler()\n",
)
.expect("cli fixture should write");
upstream
}
fn reserve_port() -> u16 {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("ephemeral port should bind");
let port = listener.local_addr().expect("local addr").port();
drop(listener);
port
}
fn unique_temp_dir(label: &str) -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after epoch")
.as_millis();
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"claw-output-format-{label}-{}-{millis}-{counter}",
std::process::id()
))
}