From 136cedf1cc6ecbeb51c56d057faa508a3d35ea2f Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Sun, 5 Apr 2026 17:29:54 +0000 Subject: [PATCH] Honor JSON output for skills and MCP inventory commands The skills and mcp inventory handlers were still emitting prose tables even when the global --output-format json flag was set. This wires structured JSON renderers into the command handlers and CLI dispatch so direct invocations and resumed slash-command execution both return machine-readable payloads while preserving existing text output in the REPL path. Constraint: Must preserve existing text output and help behavior for interactive slash commands Rejected: Parse existing prose tables into JSON at the CLI edge | brittle and loses structured fields Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep text and JSON variants driven by the same command parsing branches so --output-format stays deterministic across entry points Tested: cargo test -p commands Tested: cargo test -p rusty-claude-cli Not-tested: Manual invocation against a live user skills registry or external MCP services --- rust/crates/commands/src/lib.rs | 442 ++++++++++++++++++++++- rust/crates/rusty-claude-cli/src/main.rs | 130 +++++-- 2 files changed, 548 insertions(+), 24 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 96946f6..fa5d790 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -9,6 +9,7 @@ use runtime::{ compact_session, CompactionConfig, ConfigLoader, ConfigSource, McpOAuthConfig, McpServerConfig, ScopedMcpServerConfig, Session, }; +use serde_json::{json, Value}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommandManifestEntry { @@ -2194,6 +2195,14 @@ pub fn handle_mcp_slash_command( render_mcp_report_for(&loader, cwd, args) } +pub fn handle_mcp_slash_command_json( + args: Option<&str>, + cwd: &Path, +) -> Result { + let loader = ConfigLoader::default_for(cwd); + render_mcp_report_json_for(&loader, cwd, args) +} + pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result { if let Some(args) = normalize_optional_args(args) { if let Some(help_path) = help_path_from_args(args) { @@ -2225,6 +2234,37 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R } } +pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::io::Result { + if let Some(args) = normalize_optional_args(args) { + if let Some(help_path) = help_path_from_args(args) { + return Ok(match help_path.as_slice() { + [] => render_skills_usage_json(None), + ["install", ..] => render_skills_usage_json(Some("install")), + _ => render_skills_usage_json(Some(&help_path.join(" "))), + }); + } + } + + match normalize_optional_args(args) { + None | Some("list") => { + let roots = discover_skill_roots(cwd); + let skills = load_skills_from_roots(&roots)?; + Ok(render_skills_report_json(&skills)) + } + Some("install") => Ok(render_skills_usage_json(Some("install"))), + Some(args) if args.starts_with("install ") => { + let target = args["install ".len()..].trim(); + if target.is_empty() { + return Ok(render_skills_usage_json(Some("install"))); + } + let install = install_skill(target, cwd)?; + Ok(render_skill_install_report_json(&install)) + } + Some(args) if is_help_arg(args) => Ok(render_skills_usage_json(None)), + Some(args) => Ok(render_skills_usage_json(Some(args))), + } +} + fn render_mcp_report_for( loader: &ConfigLoader, cwd: &Path, @@ -2270,6 +2310,51 @@ fn render_mcp_report_for( } } +fn render_mcp_report_json_for( + loader: &ConfigLoader, + cwd: &Path, + args: Option<&str>, +) -> Result { + if let Some(args) = normalize_optional_args(args) { + if let Some(help_path) = help_path_from_args(args) { + return Ok(match help_path.as_slice() { + [] => render_mcp_usage_json(None), + ["show", ..] => render_mcp_usage_json(Some("show")), + _ => render_mcp_usage_json(Some(&help_path.join(" "))), + }); + } + } + + match normalize_optional_args(args) { + None | Some("list") => { + let runtime_config = loader.load()?; + Ok(render_mcp_summary_report_json( + cwd, + runtime_config.mcp().servers(), + )) + } + Some(args) if is_help_arg(args) => Ok(render_mcp_usage_json(None)), + Some("show") => Ok(render_mcp_usage_json(Some("show"))), + Some(args) if args.split_whitespace().next() == Some("show") => { + let mut parts = args.split_whitespace(); + let _ = parts.next(); + let Some(server_name) = parts.next() else { + return Ok(render_mcp_usage_json(Some("show"))); + }; + if parts.next().is_some() { + return Ok(render_mcp_usage_json(Some(args))); + } + let runtime_config = loader.load()?; + Ok(render_mcp_server_report_json( + cwd, + server_name, + runtime_config.mcp().get(server_name), + )) + } + Some(args) => Ok(render_mcp_usage_json(Some(args))), + } +} + #[must_use] pub fn render_plugins_report(plugins: &[PluginSummary]) -> String { let mut lines = vec!["Plugins".to_string()]; @@ -3016,6 +3101,23 @@ fn render_skills_report(skills: &[SkillSummary]) -> String { lines.join("\n").trim_end().to_string() } +fn render_skills_report_json(skills: &[SkillSummary]) -> Value { + let active = skills + .iter() + .filter(|skill| skill.shadowed_by.is_none()) + .count(); + json!({ + "kind": "skills", + "action": "list", + "summary": { + "total": skills.len(), + "active": active, + "shadowed": skills.len().saturating_sub(active), + }, + "skills": skills.iter().map(skill_summary_json).collect::>(), + }) +} + fn render_skill_install_report(skill: &InstalledSkill) -> String { let mut lines = vec![ "Skills".to_string(), @@ -3037,6 +3139,20 @@ fn render_skill_install_report(skill: &InstalledSkill) -> String { lines.join("\n") } +fn render_skill_install_report_json(skill: &InstalledSkill) -> Value { + json!({ + "kind": "skills", + "action": "install", + "result": "installed", + "invocation_name": &skill.invocation_name, + "invoke_as": format!("${}", skill.invocation_name), + "display_name": &skill.display_name, + "source": skill.source.display().to_string(), + "registry_root": skill.registry_root.display().to_string(), + "installed_path": skill.installed_path.display().to_string(), + }) +} + fn render_mcp_summary_report( cwd: &Path, servers: &BTreeMap, @@ -3064,6 +3180,22 @@ fn render_mcp_summary_report( lines.join("\n") } +fn render_mcp_summary_report_json( + cwd: &Path, + servers: &BTreeMap, +) -> Value { + json!({ + "kind": "mcp", + "action": "list", + "working_directory": cwd.display().to_string(), + "configured_servers": servers.len(), + "servers": servers + .iter() + .map(|(name, server)| mcp_server_json(name, server)) + .collect::>(), + }) +} + fn render_mcp_server_report( cwd: &Path, server_name: &str, @@ -3142,6 +3274,31 @@ fn render_mcp_server_report( lines.join("\n") } + +fn render_mcp_server_report_json( + cwd: &Path, + server_name: &str, + server: Option<&ScopedMcpServerConfig>, +) -> Value { + match server { + Some(server) => json!({ + "kind": "mcp", + "action": "show", + "working_directory": cwd.display().to_string(), + "found": true, + "server": mcp_server_json(server_name, server), + }), + None => json!({ + "kind": "mcp", + "action": "show", + "working_directory": cwd.display().to_string(), + "found": false, + "server_name": server_name, + "message": format!("server `{server_name}` is not configured"), + }), + } +} + fn normalize_optional_args(args: Option<&str>) -> Option<&str> { args.map(str::trim).filter(|value| !value.is_empty()) } @@ -3183,6 +3340,20 @@ fn render_skills_usage(unexpected: Option<&str>) -> String { lines.join("\n") } +fn render_skills_usage_json(unexpected: Option<&str>) -> Value { + json!({ + "kind": "skills", + "action": "help", + "usage": { + "slash_command": "/skills [list|install |help]", + "direct_cli": "claw skills [list|install |help]", + "install_root": "$CODEX_HOME/skills or ~/.codex/skills", + "sources": [".codex/skills", ".claude/skills", "legacy /commands"], + }, + "unexpected": unexpected, + }) +} + fn render_mcp_usage(unexpected: Option<&str>) -> String { let mut lines = vec![ "MCP".to_string(), @@ -3196,6 +3367,19 @@ fn render_mcp_usage(unexpected: Option<&str>) -> String { lines.join("\n") } +fn render_mcp_usage_json(unexpected: Option<&str>) -> Value { + json!({ + "kind": "mcp", + "action": "help", + "usage": { + "slash_command": "/mcp [list|show |help]", + "direct_cli": "claw mcp [list|show |help]", + "sources": [".claw/settings.json", ".claw/settings.local.json"], + }, + "unexpected": unexpected, + }) +} + fn config_source_label(source: ConfigSource) -> &'static str { match source { ConfigSource::User => "user", @@ -3272,6 +3456,122 @@ fn format_mcp_oauth(oauth: Option<&McpOAuthConfig>) -> String { } } +fn definition_source_id(source: DefinitionSource) -> &'static str { + match source { + DefinitionSource::ProjectCodex => "project_codex", + DefinitionSource::ProjectClaude => "project_claude", + DefinitionSource::UserCodexHome => "user_codex_home", + DefinitionSource::UserCodex => "user_codex", + DefinitionSource::UserClaude => "user_claude", + } +} + +fn definition_source_json(source: DefinitionSource) -> Value { + json!({ + "id": definition_source_id(source), + "label": source.label(), + }) +} + +fn skill_origin_id(origin: SkillOrigin) -> &'static str { + match origin { + SkillOrigin::SkillsDir => "skills_dir", + SkillOrigin::LegacyCommandsDir => "legacy_commands_dir", + } +} + +fn skill_origin_json(origin: SkillOrigin) -> Value { + json!({ + "id": skill_origin_id(origin), + "detail_label": origin.detail_label(), + }) +} + +fn skill_summary_json(skill: &SkillSummary) -> Value { + json!({ + "name": &skill.name, + "description": &skill.description, + "source": definition_source_json(skill.source), + "origin": skill_origin_json(skill.origin), + "active": skill.shadowed_by.is_none(), + "shadowed_by": skill.shadowed_by.map(definition_source_json), + }) +} + +fn config_source_id(source: ConfigSource) -> &'static str { + match source { + ConfigSource::User => "user", + ConfigSource::Project => "project", + ConfigSource::Local => "local", + } +} + +fn config_source_json(source: ConfigSource) -> Value { + json!({ + "id": config_source_id(source), + "label": config_source_label(source), + }) +} + +fn mcp_transport_json(config: &McpServerConfig) -> Value { + let label = mcp_transport_label(config); + json!({ + "id": label, + "label": label, + }) +} + +fn mcp_oauth_json(oauth: Option<&McpOAuthConfig>) -> Value { + let Some(oauth) = oauth else { + return Value::Null; + }; + json!({ + "client_id": &oauth.client_id, + "callback_port": oauth.callback_port, + "auth_server_metadata_url": &oauth.auth_server_metadata_url, + "xaa": oauth.xaa, + }) +} + +fn mcp_server_details_json(config: &McpServerConfig) -> Value { + match config { + McpServerConfig::Stdio(config) => json!({ + "command": &config.command, + "args": &config.args, + "env_keys": config.env.keys().cloned().collect::>(), + "tool_call_timeout_ms": config.tool_call_timeout_ms, + }), + McpServerConfig::Sse(config) | McpServerConfig::Http(config) => json!({ + "url": &config.url, + "header_keys": config.headers.keys().cloned().collect::>(), + "headers_helper": &config.headers_helper, + "oauth": mcp_oauth_json(config.oauth.as_ref()), + }), + McpServerConfig::Ws(config) => json!({ + "url": &config.url, + "header_keys": config.headers.keys().cloned().collect::>(), + "headers_helper": &config.headers_helper, + }), + McpServerConfig::Sdk(config) => json!({ + "name": &config.name, + }), + McpServerConfig::ManagedProxy(config) => json!({ + "url": &config.url, + "id": &config.id, + }), + } +} + +fn mcp_server_json(name: &str, server: &ScopedMcpServerConfig) -> Value { + json!({ + "name": name, + "scope": config_source_json(server.scope), + "transport": mcp_transport_json(&server.config), + "summary": mcp_server_summary(&server.config), + "details": mcp_server_details_json(&server.config), + }) +} + #[must_use] pub fn handle_slash_command( input: &str, @@ -3381,8 +3681,9 @@ pub fn handle_slash_command( #[cfg(test)] mod tests { use super::{ - handle_plugins_slash_command, handle_slash_command, load_agents_from_roots, - load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report, + handle_plugins_slash_command, handle_skills_slash_command_json, handle_slash_command, + load_agents_from_roots, load_skills_from_roots, render_agents_report, + render_mcp_report_json_for, render_plugins_report, render_skills_report, render_slash_command_help, render_slash_command_help_detail, resume_supported_slash_commands, slash_command_specs, suggest_slash_commands, validate_slash_command_input, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand, @@ -4103,6 +4404,61 @@ mod tests { let _ = fs::remove_dir_all(user_home); } + #[test] + fn renders_skills_reports_as_json() { + let workspace = temp_dir("skills-json-workspace"); + let project_skills = workspace.join(".codex").join("skills"); + let project_commands = workspace.join(".claude").join("commands"); + let user_home = temp_dir("skills-json-home"); + let user_skills = user_home.join(".codex").join("skills"); + + write_skill(&project_skills, "plan", "Project planning guidance"); + write_legacy_command(&project_commands, "deploy", "Legacy deployment guidance"); + write_skill(&user_skills, "plan", "User planning guidance"); + write_skill(&user_skills, "help", "Help guidance"); + + let roots = vec![ + SkillRoot { + source: DefinitionSource::ProjectCodex, + path: project_skills, + origin: SkillOrigin::SkillsDir, + }, + SkillRoot { + source: DefinitionSource::ProjectClaude, + path: project_commands, + origin: SkillOrigin::LegacyCommandsDir, + }, + SkillRoot { + source: DefinitionSource::UserCodex, + path: user_skills, + origin: SkillOrigin::SkillsDir, + }, + ]; + let report = super::render_skills_report_json( + &load_skills_from_roots(&roots).expect("skills should load"), + ); + assert_eq!(report["kind"], "skills"); + assert_eq!(report["action"], "list"); + assert_eq!(report["summary"]["active"], 3); + assert_eq!(report["summary"]["shadowed"], 1); + assert_eq!(report["skills"][0]["name"], "plan"); + assert_eq!(report["skills"][0]["source"]["id"], "project_codex"); + assert_eq!(report["skills"][1]["name"], "deploy"); + assert_eq!(report["skills"][1]["origin"]["id"], "legacy_commands_dir"); + assert_eq!(report["skills"][3]["shadowed_by"]["id"], "project_codex"); + + let help = handle_skills_slash_command_json(Some("help"), &workspace).expect("skills help"); + assert_eq!(help["kind"], "skills"); + assert_eq!(help["action"], "help"); + assert_eq!( + help["usage"]["direct_cli"], + "claw skills [list|install |help]" + ); + + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(user_home); + } + #[test] fn agents_and_skills_usage_support_help_and_unexpected_args() { let cwd = temp_dir("slash-usage"); @@ -4243,6 +4599,88 @@ mod tests { let _ = fs::remove_dir_all(config_home); } + #[test] + fn renders_mcp_reports_as_json() { + let workspace = temp_dir("mcp-json-workspace"); + let config_home = temp_dir("mcp-json-home"); + fs::create_dir_all(workspace.join(".claw")).expect("workspace config dir"); + fs::create_dir_all(&config_home).expect("config home"); + fs::write( + workspace.join(".claw").join("settings.json"), + r#"{ + "mcpServers": { + "alpha": { + "command": "uvx", + "args": ["alpha-server"], + "env": {"ALPHA_TOKEN": "secret"}, + "toolCallTimeoutMs": 1200 + }, + "remote": { + "type": "http", + "url": "https://remote.example/mcp", + "headers": {"Authorization": "Bearer secret"}, + "headersHelper": "./bin/headers", + "oauth": { + "clientId": "remote-client", + "callbackPort": 7878 + } + } + } + }"#, + ) + .expect("write settings"); + fs::write( + workspace.join(".claw").join("settings.local.json"), + r#"{ + "mcpServers": { + "remote": { + "type": "ws", + "url": "wss://remote.example/mcp" + } + } + }"#, + ) + .expect("write local settings"); + + let loader = ConfigLoader::new(&workspace, &config_home); + let list = + render_mcp_report_json_for(&loader, &workspace, None).expect("mcp list json render"); + assert_eq!(list["kind"], "mcp"); + assert_eq!(list["action"], "list"); + assert_eq!(list["configured_servers"], 2); + assert_eq!(list["servers"][0]["name"], "alpha"); + assert_eq!(list["servers"][0]["transport"]["id"], "stdio"); + assert_eq!(list["servers"][0]["details"]["command"], "uvx"); + assert_eq!(list["servers"][1]["name"], "remote"); + assert_eq!(list["servers"][1]["scope"]["id"], "local"); + assert_eq!(list["servers"][1]["transport"]["id"], "ws"); + assert_eq!( + list["servers"][1]["details"]["url"], + "wss://remote.example/mcp" + ); + + let show = render_mcp_report_json_for(&loader, &workspace, Some("show alpha")) + .expect("mcp show json render"); + assert_eq!(show["action"], "show"); + assert_eq!(show["found"], true); + assert_eq!(show["server"]["name"], "alpha"); + assert_eq!(show["server"]["details"]["env_keys"][0], "ALPHA_TOKEN"); + assert_eq!(show["server"]["details"]["tool_call_timeout_ms"], 1200); + + let missing = render_mcp_report_json_for(&loader, &workspace, Some("show missing")) + .expect("mcp missing json render"); + assert_eq!(missing["found"], false); + assert_eq!(missing["server_name"], "missing"); + + let help = + render_mcp_report_json_for(&loader, &workspace, Some("help")).expect("mcp help json"); + assert_eq!(help["action"], "help"); + assert_eq!(help["usage"]["sources"][0], ".claw/settings.json"); + + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(config_home); + } + #[test] fn parses_quoted_skill_frontmatter_values() { let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n"; diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index dbf9311..435f431 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -31,9 +31,10 @@ use api::{ }; use commands::{ - handle_agents_slash_command, handle_mcp_slash_command, handle_plugins_slash_command, - handle_skills_slash_command, render_slash_command_help, resume_supported_slash_commands, - slash_command_specs, validate_slash_command_input, SlashCommand, + handle_agents_slash_command, handle_mcp_slash_command, handle_mcp_slash_command_json, + handle_plugins_slash_command, handle_skills_slash_command, handle_skills_slash_command_json, + render_slash_command_help, resume_supported_slash_commands, slash_command_specs, + validate_slash_command_input, SlashCommand, }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; @@ -111,8 +112,14 @@ fn run() -> Result<(), Box> { CliAction::DumpManifests => dump_manifests(), CliAction::BootstrapPlan => print_bootstrap_plan(), CliAction::Agents { args } => LiveCli::print_agents(args.as_deref())?, - CliAction::Mcp { args } => LiveCli::print_mcp(args.as_deref())?, - CliAction::Skills { args } => LiveCli::print_skills(args.as_deref())?, + 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 } => print_system_prompt(cwd, date), CliAction::Version => print_version(), CliAction::ResumeSession { @@ -156,9 +163,11 @@ enum CliAction { }, Mcp { args: Option, + output_format: CliOutputFormat, }, Skills { args: Option, + output_format: CliOutputFormat, }, PrintSystemPrompt { cwd: PathBuf, @@ -370,9 +379,11 @@ fn parse_args(args: &[String]) -> Result { }), "mcp" => Ok(CliAction::Mcp { args: join_optional_args(&rest[1..]), + output_format, }), "skills" => Ok(CliAction::Skills { args: join_optional_args(&rest[1..]), + output_format, }), "system-prompt" => parse_system_prompt_args(&rest[1..]), "login" => Ok(CliAction::Login), @@ -391,7 +402,7 @@ fn parse_args(args: &[String]) -> Result { 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 { prompt: rest.join(" "), model, @@ -479,7 +490,10 @@ fn join_optional_args(args: &[String]) -> Option { (!trimmed.is_empty()).then(|| trimmed.to_string()) } -fn parse_direct_slash_cli_action(rest: &[String]) -> Result { +fn parse_direct_slash_cli_action( + rest: &[String], + output_format: CliOutputFormat, +) -> Result { let raw = rest.join(" "); match SlashCommand::parse(&raw) { Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help), @@ -491,8 +505,12 @@ fn parse_direct_slash_cli_action(rest: &[String]) -> Result { (Some(action), Some(target)) => Some(format!("{action} {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(command)) => Err({ let _ = command; @@ -1844,7 +1862,13 @@ fn run_resume_command( }; Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)?), + message: Some(match output_format { + CliOutputFormat::Text => json!({ + "kind": "mcp", + "message": handle_mcp_slash_command(args.as_deref(), &cwd)?, + }), + CliOutputFormat::Json => handle_mcp_slash_command_json(args.as_deref(), &cwd)?, + }), }) } SlashCommand::Memory => Ok(ResumeCommandOutcome { @@ -1888,7 +1912,15 @@ fn run_resume_command( let cwd = env::current_dir()?; Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?), + message: Some(match output_format { + CliOutputFormat::Text => json!({ + "kind": "skills", + "message": handle_skills_slash_command(args.as_deref(), &cwd)?, + }), + CliOutputFormat::Json => { + handle_skills_slash_command_json(args.as_deref(), &cwd)? + } + }), }) } SlashCommand::Doctor => Ok(ResumeCommandOutcome { @@ -2777,7 +2809,7 @@ impl LiveCli { (Some(action), Some(target)) => Some(format!("{action} {target}")), (None, Some(target)) => Some(target.to_string()), }; - Self::print_mcp(args.as_deref())?; + Self::print_mcp(args.as_deref(), CliOutputFormat::Text)?; false } SlashCommand::Memory => { @@ -2811,7 +2843,7 @@ impl LiveCli { false } SlashCommand::Skills { args } => { - Self::print_skills(args.as_deref())?; + Self::print_skills(args.as_deref(), CliOutputFormat::Text)?; false } SlashCommand::Doctor => { @@ -3095,15 +3127,33 @@ impl LiveCli { Ok(()) } - fn print_mcp(args: Option<&str>) -> Result<(), Box> { + fn print_mcp( + args: Option<&str>, + output_format: CliOutputFormat, + ) -> Result<(), Box> { let cwd = env::current_dir()?; - println!("{}", handle_mcp_slash_command(args, &cwd)?); + match output_format { + CliOutputFormat::Text => println!("{}", handle_mcp_slash_command(args, &cwd)?), + CliOutputFormat::Json => println!( + "{}", + serialize_json_output(&handle_mcp_slash_command_json(args, &cwd)?)? + ), + } Ok(()) } - fn print_skills(args: Option<&str>) -> Result<(), Box> { + fn print_skills( + args: Option<&str>, + output_format: CliOutputFormat, + ) -> Result<(), Box> { let cwd = env::current_dir()?; - println!("{}", handle_skills_slash_command(args, &cwd)?); + match output_format { + CliOutputFormat::Text => println!("{}", handle_skills_slash_command(args, &cwd)?), + CliOutputFormat::Json => println!( + "{}", + serialize_json_output(&handle_skills_slash_command_json(args, &cwd)?)? + ), + } Ok(()) } @@ -6498,11 +6548,17 @@ mod tests { ); assert_eq!( parse_args(&["mcp".to_string()]).expect("mcp should parse"), - CliAction::Mcp { args: None } + CliAction::Mcp { + args: None, + output_format: CliOutputFormat::Text, + } ); assert_eq!( parse_args(&["skills".to_string()]).expect("skills should parse"), - CliAction::Skills { args: None } + CliAction::Skills { + args: None, + output_format: CliOutputFormat::Text, + } ); assert_eq!( parse_args(&["agents".to_string(), "--help".to_string()]) @@ -6557,6 +6613,30 @@ mod tests { ); } + #[test] + fn parses_json_output_for_mcp_and_skills_commands() { + assert_eq!( + parse_args(&["--output-format=json".to_string(), "mcp".to_string()]) + .expect("json mcp should parse"), + CliAction::Mcp { + args: None, + output_format: CliOutputFormat::Json, + } + ); + assert_eq!( + parse_args(&[ + "--output-format=json".to_string(), + "/skills".to_string(), + "help".to_string(), + ]) + .expect("json /skills help should parse"), + CliAction::Skills { + args: Some("help".to_string()), + output_format: CliOutputFormat::Json, + } + ); + } + #[test] fn single_word_slash_command_names_return_guidance_instead_of_hitting_prompt_mode() { let error = parse_args(&["cost".to_string()]).expect_err("cost should return guidance"); @@ -6591,18 +6671,23 @@ mod tests { parse_args(&["/mcp".to_string(), "show".to_string(), "demo".to_string()]) .expect("/mcp show demo should parse"), CliAction::Mcp { - args: Some("show demo".to_string()) + args: Some("show demo".to_string()), + output_format: CliOutputFormat::Text, } ); assert_eq!( parse_args(&["/skills".to_string()]).expect("/skills should parse"), - CliAction::Skills { args: None } + CliAction::Skills { + args: None, + output_format: CliOutputFormat::Text, + } ); assert_eq!( parse_args(&["/skills".to_string(), "help".to_string()]) .expect("/skills help should parse"), CliAction::Skills { - args: Some("help".to_string()) + args: Some("help".to_string()), + output_format: CliOutputFormat::Text, } ); assert_eq!( @@ -6613,7 +6698,8 @@ mod tests { ]) .expect("/skills install should parse"), 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()])