mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-06 08:04:50 +08:00
merge: clawcode-issue-9407-cli-agents-mcp-config into main
This commit is contained in:
@@ -5,7 +5,10 @@ use std::fs;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use plugins::{PluginError, PluginManager, PluginSummary};
|
use plugins::{PluginError, PluginManager, PluginSummary};
|
||||||
use runtime::{compact_session, CompactionConfig, Session};
|
use runtime::{
|
||||||
|
compact_session, CompactionConfig, ConfigLoader, ConfigSource, McpOAuthConfig, McpServerConfig,
|
||||||
|
ScopedMcpServerConfig, Session,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct CommandManifestEntry {
|
pub struct CommandManifestEntry {
|
||||||
@@ -117,6 +120,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||||||
argument_hint: Some("[env|hooks|model|plugins]"),
|
argument_hint: Some("[env|hooks|model|plugins]"),
|
||||||
resume_supported: true,
|
resume_supported: true,
|
||||||
},
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "mcp",
|
||||||
|
aliases: &[],
|
||||||
|
summary: "Inspect configured MCP servers",
|
||||||
|
argument_hint: Some("[list|show <server>|help]"),
|
||||||
|
resume_supported: true,
|
||||||
|
},
|
||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
name: "memory",
|
name: "memory",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
@@ -272,6 +282,10 @@ pub enum SlashCommand {
|
|||||||
Config {
|
Config {
|
||||||
section: Option<String>,
|
section: Option<String>,
|
||||||
},
|
},
|
||||||
|
Mcp {
|
||||||
|
action: Option<String>,
|
||||||
|
target: Option<String>,
|
||||||
|
},
|
||||||
Memory,
|
Memory,
|
||||||
Init,
|
Init,
|
||||||
Diff,
|
Diff,
|
||||||
@@ -393,6 +407,7 @@ pub fn validate_slash_command_input(
|
|||||||
"config" => SlashCommand::Config {
|
"config" => SlashCommand::Config {
|
||||||
section: parse_config_section(&args)?,
|
section: parse_config_section(&args)?,
|
||||||
},
|
},
|
||||||
|
"mcp" => parse_mcp_command(&args)?,
|
||||||
"memory" => {
|
"memory" => {
|
||||||
validate_no_args(command, &args)?;
|
validate_no_args(command, &args)?;
|
||||||
SlashCommand::Memory
|
SlashCommand::Memory
|
||||||
@@ -551,6 +566,39 @@ fn parse_session_command(args: &[&str]) -> Result<SlashCommand, SlashCommandPars
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_mcp_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
|
||||||
|
match args {
|
||||||
|
[] => Ok(SlashCommand::Mcp {
|
||||||
|
action: None,
|
||||||
|
target: None,
|
||||||
|
}),
|
||||||
|
["list"] => Ok(SlashCommand::Mcp {
|
||||||
|
action: Some("list".to_string()),
|
||||||
|
target: None,
|
||||||
|
}),
|
||||||
|
["list", ..] => Err(usage_error("mcp list", "")),
|
||||||
|
["show"] => Err(usage_error("mcp show", "<server>")),
|
||||||
|
["show", target] => Ok(SlashCommand::Mcp {
|
||||||
|
action: Some("show".to_string()),
|
||||||
|
target: Some((*target).to_string()),
|
||||||
|
}),
|
||||||
|
["show", ..] => Err(command_error(
|
||||||
|
"Unexpected arguments for /mcp show.",
|
||||||
|
"mcp",
|
||||||
|
"/mcp show <server>",
|
||||||
|
)),
|
||||||
|
["help"] | ["-h"] | ["--help"] => Ok(SlashCommand::Mcp {
|
||||||
|
action: Some("help".to_string()),
|
||||||
|
target: None,
|
||||||
|
}),
|
||||||
|
[action, ..] => Err(command_error(
|
||||||
|
&format!("Unknown /mcp action '{action}'. Use list, show <server>, or help."),
|
||||||
|
"mcp",
|
||||||
|
"/mcp [list|show <server>|help]",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_plugin_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
|
fn parse_plugin_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
|
||||||
match args {
|
match args {
|
||||||
[] => Ok(SlashCommand::Plugins {
|
[] => Ok(SlashCommand::Plugins {
|
||||||
@@ -760,7 +808,7 @@ fn slash_command_category(name: &str) -> &'static str {
|
|||||||
| "version" => "Session & visibility",
|
| "version" => "Session & visibility",
|
||||||
"compact" | "clear" | "config" | "memory" | "init" | "diff" | "commit" | "pr" | "issue"
|
"compact" | "clear" | "config" | "memory" | "init" | "diff" | "commit" | "pr" | "issue"
|
||||||
| "export" | "plugin" => "Workspace & git",
|
| "export" | "plugin" => "Workspace & git",
|
||||||
"agents" | "skills" | "teleport" | "debug-tool-call" => "Discovery & debugging",
|
"agents" | "skills" | "teleport" | "debug-tool-call" | "mcp" => "Discovery & debugging",
|
||||||
"bughunter" | "ultraplan" => "Analysis & automation",
|
"bughunter" | "ultraplan" => "Analysis & automation",
|
||||||
_ => "Other",
|
_ => "Other",
|
||||||
}
|
}
|
||||||
@@ -1113,6 +1161,14 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn handle_mcp_slash_command(
|
||||||
|
args: Option<&str>,
|
||||||
|
cwd: &Path,
|
||||||
|
) -> Result<String, runtime::ConfigError> {
|
||||||
|
let loader = ConfigLoader::default_for(cwd);
|
||||||
|
render_mcp_report_for(&loader, cwd, args)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
|
pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
|
||||||
match normalize_optional_args(args) {
|
match normalize_optional_args(args) {
|
||||||
None | Some("list") => {
|
None | Some("list") => {
|
||||||
@@ -1134,6 +1190,41 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_mcp_report_for(
|
||||||
|
loader: &ConfigLoader,
|
||||||
|
cwd: &Path,
|
||||||
|
args: Option<&str>,
|
||||||
|
) -> Result<String, runtime::ConfigError> {
|
||||||
|
match normalize_optional_args(args) {
|
||||||
|
None | Some("list") => {
|
||||||
|
let runtime_config = loader.load()?;
|
||||||
|
Ok(render_mcp_summary_report(
|
||||||
|
cwd,
|
||||||
|
runtime_config.mcp().servers(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Some("-h" | "--help" | "help") => Ok(render_mcp_usage(None)),
|
||||||
|
Some("show") => Ok(render_mcp_usage(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(Some("show")));
|
||||||
|
};
|
||||||
|
if parts.next().is_some() {
|
||||||
|
return Ok(render_mcp_usage(Some(args)));
|
||||||
|
}
|
||||||
|
let runtime_config = loader.load()?;
|
||||||
|
Ok(render_mcp_server_report(
|
||||||
|
cwd,
|
||||||
|
server_name,
|
||||||
|
runtime_config.mcp().get(server_name),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Some(args) => Ok(render_mcp_usage(Some(args))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
|
pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
|
||||||
let mut lines = vec!["Plugins".to_string()];
|
let mut lines = vec!["Plugins".to_string()];
|
||||||
@@ -1844,6 +1935,111 @@ fn render_skill_install_report(skill: &InstalledSkill) -> String {
|
|||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_mcp_summary_report(
|
||||||
|
cwd: &Path,
|
||||||
|
servers: &BTreeMap<String, ScopedMcpServerConfig>,
|
||||||
|
) -> String {
|
||||||
|
let mut lines = vec![
|
||||||
|
"MCP".to_string(),
|
||||||
|
format!(" Working directory {}", cwd.display()),
|
||||||
|
format!(" Configured servers {}", servers.len()),
|
||||||
|
];
|
||||||
|
if servers.is_empty() {
|
||||||
|
lines.push(" No MCP servers configured.".to_string());
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(String::new());
|
||||||
|
for (name, server) in servers {
|
||||||
|
lines.push(format!(
|
||||||
|
" {name:<16} {transport:<13} {scope:<7} {summary}",
|
||||||
|
transport = mcp_transport_label(&server.config),
|
||||||
|
scope = config_source_label(server.scope),
|
||||||
|
summary = mcp_server_summary(&server.config)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_mcp_server_report(
|
||||||
|
cwd: &Path,
|
||||||
|
server_name: &str,
|
||||||
|
server: Option<&ScopedMcpServerConfig>,
|
||||||
|
) -> String {
|
||||||
|
let Some(server) = server else {
|
||||||
|
return format!(
|
||||||
|
"MCP\n Working directory {}\n Result server `{server_name}` is not configured",
|
||||||
|
cwd.display()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut lines = vec![
|
||||||
|
"MCP".to_string(),
|
||||||
|
format!(" Working directory {}", cwd.display()),
|
||||||
|
format!(" Name {server_name}"),
|
||||||
|
format!(" Scope {}", config_source_label(server.scope)),
|
||||||
|
format!(
|
||||||
|
" Transport {}",
|
||||||
|
mcp_transport_label(&server.config)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
match &server.config {
|
||||||
|
McpServerConfig::Stdio(config) => {
|
||||||
|
lines.push(format!(" Command {}", config.command));
|
||||||
|
lines.push(format!(
|
||||||
|
" Args {}",
|
||||||
|
format_optional_list(&config.args)
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
" Env keys {}",
|
||||||
|
format_optional_keys(config.env.keys().cloned().collect())
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
" Tool timeout {}",
|
||||||
|
config
|
||||||
|
.tool_call_timeout_ms
|
||||||
|
.map_or_else(|| "<default>".to_string(), |value| format!("{value} ms"))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
McpServerConfig::Sse(config) | McpServerConfig::Http(config) => {
|
||||||
|
lines.push(format!(" URL {}", config.url));
|
||||||
|
lines.push(format!(
|
||||||
|
" Header keys {}",
|
||||||
|
format_optional_keys(config.headers.keys().cloned().collect())
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
" Header helper {}",
|
||||||
|
config.headers_helper.as_deref().unwrap_or("<none>")
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
" OAuth {}",
|
||||||
|
format_mcp_oauth(config.oauth.as_ref())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
McpServerConfig::Ws(config) => {
|
||||||
|
lines.push(format!(" URL {}", config.url));
|
||||||
|
lines.push(format!(
|
||||||
|
" Header keys {}",
|
||||||
|
format_optional_keys(config.headers.keys().cloned().collect())
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
" Header helper {}",
|
||||||
|
config.headers_helper.as_deref().unwrap_or("<none>")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
McpServerConfig::Sdk(config) => {
|
||||||
|
lines.push(format!(" SDK name {}", config.name));
|
||||||
|
}
|
||||||
|
McpServerConfig::ManagedProxy(config) => {
|
||||||
|
lines.push(format!(" URL {}", config.url));
|
||||||
|
lines.push(format!(" Proxy id {}", config.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
|
fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
|
||||||
args.map(str::trim).filter(|value| !value.is_empty())
|
args.map(str::trim).filter(|value| !value.is_empty())
|
||||||
}
|
}
|
||||||
@@ -1875,6 +2071,95 @@ fn render_skills_usage(unexpected: Option<&str>) -> String {
|
|||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_mcp_usage(unexpected: Option<&str>) -> String {
|
||||||
|
let mut lines = vec![
|
||||||
|
"MCP".to_string(),
|
||||||
|
" Usage /mcp [list|show <server>|help]".to_string(),
|
||||||
|
" Direct CLI claw mcp [list|show <server>|help]".to_string(),
|
||||||
|
" Sources .claw/settings.json, .claw/settings.local.json".to_string(),
|
||||||
|
];
|
||||||
|
if let Some(args) = unexpected {
|
||||||
|
lines.push(format!(" Unexpected {args}"));
|
||||||
|
}
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_source_label(source: ConfigSource) -> &'static str {
|
||||||
|
match source {
|
||||||
|
ConfigSource::User => "user",
|
||||||
|
ConfigSource::Project => "project",
|
||||||
|
ConfigSource::Local => "local",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mcp_transport_label(config: &McpServerConfig) -> &'static str {
|
||||||
|
match config {
|
||||||
|
McpServerConfig::Stdio(_) => "stdio",
|
||||||
|
McpServerConfig::Sse(_) => "sse",
|
||||||
|
McpServerConfig::Http(_) => "http",
|
||||||
|
McpServerConfig::Ws(_) => "ws",
|
||||||
|
McpServerConfig::Sdk(_) => "sdk",
|
||||||
|
McpServerConfig::ManagedProxy(_) => "managed-proxy",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mcp_server_summary(config: &McpServerConfig) -> String {
|
||||||
|
match config {
|
||||||
|
McpServerConfig::Stdio(config) => {
|
||||||
|
if config.args.is_empty() {
|
||||||
|
config.command.clone()
|
||||||
|
} else {
|
||||||
|
format!("{} {}", config.command, config.args.join(" "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
McpServerConfig::Sse(config) | McpServerConfig::Http(config) => config.url.clone(),
|
||||||
|
McpServerConfig::Ws(config) => config.url.clone(),
|
||||||
|
McpServerConfig::Sdk(config) => config.name.clone(),
|
||||||
|
McpServerConfig::ManagedProxy(config) => format!("{} ({})", config.id, config.url),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_optional_list(values: &[String]) -> String {
|
||||||
|
if values.is_empty() {
|
||||||
|
"<none>".to_string()
|
||||||
|
} else {
|
||||||
|
values.join(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_optional_keys(mut keys: Vec<String>) -> String {
|
||||||
|
if keys.is_empty() {
|
||||||
|
return "<none>".to_string();
|
||||||
|
}
|
||||||
|
keys.sort();
|
||||||
|
keys.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_mcp_oauth(oauth: Option<&McpOAuthConfig>) -> String {
|
||||||
|
let Some(oauth) = oauth else {
|
||||||
|
return "<none>".to_string();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if let Some(client_id) = &oauth.client_id {
|
||||||
|
parts.push(format!("client_id={client_id}"));
|
||||||
|
}
|
||||||
|
if let Some(port) = oauth.callback_port {
|
||||||
|
parts.push(format!("callback_port={port}"));
|
||||||
|
}
|
||||||
|
if let Some(url) = &oauth.auth_server_metadata_url {
|
||||||
|
parts.push(format!("metadata_url={url}"));
|
||||||
|
}
|
||||||
|
if let Some(xaa) = oauth.xaa {
|
||||||
|
parts.push(format!("xaa={xaa}"));
|
||||||
|
}
|
||||||
|
if parts.is_empty() {
|
||||||
|
"enabled".to_string()
|
||||||
|
} else {
|
||||||
|
parts.join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn handle_slash_command(
|
pub fn handle_slash_command(
|
||||||
input: &str,
|
input: &str,
|
||||||
@@ -1927,6 +2212,7 @@ pub fn handle_slash_command(
|
|||||||
| SlashCommand::Cost
|
| SlashCommand::Cost
|
||||||
| SlashCommand::Resume { .. }
|
| SlashCommand::Resume { .. }
|
||||||
| SlashCommand::Config { .. }
|
| SlashCommand::Config { .. }
|
||||||
|
| SlashCommand::Mcp { .. }
|
||||||
| SlashCommand::Memory
|
| SlashCommand::Memory
|
||||||
| SlashCommand::Init
|
| SlashCommand::Init
|
||||||
| SlashCommand::Diff
|
| SlashCommand::Diff
|
||||||
@@ -1950,7 +2236,9 @@ mod tests {
|
|||||||
validate_slash_command_input, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
|
validate_slash_command_input, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
|
||||||
};
|
};
|
||||||
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
||||||
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
use runtime::{
|
||||||
|
CompactionConfig, ConfigLoader, ContentBlock, ConversationMessage, MessageRole, Session,
|
||||||
|
};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -2151,6 +2439,20 @@ mod tests {
|
|||||||
section: Some("env".to_string())
|
section: Some("env".to_string())
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/mcp"),
|
||||||
|
Ok(Some(SlashCommand::Mcp {
|
||||||
|
action: None,
|
||||||
|
target: None
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/mcp show remote"),
|
||||||
|
Ok(Some(SlashCommand::Mcp {
|
||||||
|
action: Some("show".to_string()),
|
||||||
|
target: Some("remote".to_string())
|
||||||
|
}))
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
SlashCommand::parse("/memory"),
|
SlashCommand::parse("/memory"),
|
||||||
Ok(Some(SlashCommand::Memory))
|
Ok(Some(SlashCommand::Memory))
|
||||||
@@ -2299,6 +2601,18 @@ mod tests {
|
|||||||
assert!(skills_error.contains(" Usage /skills [list|install <path>|help]"));
|
assert!(skills_error.contains(" Usage /skills [list|install <path>|help]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_mcp_arguments() {
|
||||||
|
let show_error = parse_error_message("/mcp show alpha beta");
|
||||||
|
assert!(show_error.contains("Unexpected arguments for /mcp show."));
|
||||||
|
assert!(show_error.contains(" Usage /mcp show <server>"));
|
||||||
|
|
||||||
|
let action_error = parse_error_message("/mcp inspect alpha");
|
||||||
|
assert!(action_error
|
||||||
|
.contains("Unknown /mcp action 'inspect'. Use list, show <server>, or help."));
|
||||||
|
assert!(action_error.contains(" Usage /mcp [list|show <server>|help]"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn renders_help_from_shared_specs() {
|
fn renders_help_from_shared_specs() {
|
||||||
let help = render_slash_command_help();
|
let help = render_slash_command_help();
|
||||||
@@ -2325,6 +2639,7 @@ mod tests {
|
|||||||
assert!(help.contains("/cost"));
|
assert!(help.contains("/cost"));
|
||||||
assert!(help.contains("/resume <session-path>"));
|
assert!(help.contains("/resume <session-path>"));
|
||||||
assert!(help.contains("/config [env|hooks|model|plugins]"));
|
assert!(help.contains("/config [env|hooks|model|plugins]"));
|
||||||
|
assert!(help.contains("/mcp [list|show <server>|help]"));
|
||||||
assert!(help.contains("/memory"));
|
assert!(help.contains("/memory"));
|
||||||
assert!(help.contains("/init"));
|
assert!(help.contains("/init"));
|
||||||
assert!(help.contains("/diff"));
|
assert!(help.contains("/diff"));
|
||||||
@@ -2338,8 +2653,8 @@ mod tests {
|
|||||||
assert!(help.contains("aliases: /plugins, /marketplace"));
|
assert!(help.contains("aliases: /plugins, /marketplace"));
|
||||||
assert!(help.contains("/agents [list|help]"));
|
assert!(help.contains("/agents [list|help]"));
|
||||||
assert!(help.contains("/skills [list|install <path>|help]"));
|
assert!(help.contains("/skills [list|install <path>|help]"));
|
||||||
assert_eq!(slash_command_specs().len(), 26);
|
assert_eq!(slash_command_specs().len(), 27);
|
||||||
assert_eq!(resume_supported_slash_commands().len(), 14);
|
assert_eq!(resume_supported_slash_commands().len(), 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2357,6 +2672,15 @@ mod tests {
|
|||||||
assert!(help.contains("Category Workspace & git"));
|
assert!(help.contains("Category Workspace & git"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_per_command_help_detail_for_mcp() {
|
||||||
|
let help = render_slash_command_help_detail("mcp").expect("detail help should exist");
|
||||||
|
assert!(help.contains("/mcp"));
|
||||||
|
assert!(help.contains("Summary Inspect configured MCP servers"));
|
||||||
|
assert!(help.contains("Category Discovery & debugging"));
|
||||||
|
assert!(help.contains("Resume Supported with --resume SESSION.jsonl"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_slash_command_input_rejects_extra_single_value_arguments() {
|
fn validate_slash_command_input_rejects_extra_single_value_arguments() {
|
||||||
// given
|
// given
|
||||||
@@ -2491,6 +2815,7 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
|
handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
|
||||||
);
|
);
|
||||||
|
assert!(handle_slash_command("/mcp list", &session, CompactionConfig::default()).is_none());
|
||||||
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
|
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
|
||||||
assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
|
assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
|
||||||
assert!(
|
assert!(
|
||||||
@@ -2665,6 +2990,98 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(cwd);
|
let _ = fs::remove_dir_all(cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_usage_supports_help_and_unexpected_args() {
|
||||||
|
let cwd = temp_dir("mcp-usage");
|
||||||
|
|
||||||
|
let help = super::handle_mcp_slash_command(Some("help"), &cwd).expect("mcp help");
|
||||||
|
assert!(help.contains("Usage /mcp [list|show <server>|help]"));
|
||||||
|
assert!(help.contains("Direct CLI claw mcp [list|show <server>|help]"));
|
||||||
|
|
||||||
|
let unexpected =
|
||||||
|
super::handle_mcp_slash_command(Some("show alpha beta"), &cwd).expect("mcp usage");
|
||||||
|
assert!(unexpected.contains("Unexpected show alpha beta"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_mcp_reports_from_loaded_config() {
|
||||||
|
let workspace = temp_dir("mcp-config-workspace");
|
||||||
|
let config_home = temp_dir("mcp-config-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 = super::render_mcp_report_for(&loader, &workspace, None)
|
||||||
|
.expect("mcp list report should render");
|
||||||
|
assert!(list.contains("Configured servers 2"));
|
||||||
|
assert!(list.contains("alpha"));
|
||||||
|
assert!(list.contains("stdio"));
|
||||||
|
assert!(list.contains("project"));
|
||||||
|
assert!(list.contains("uvx alpha-server"));
|
||||||
|
assert!(list.contains("remote"));
|
||||||
|
assert!(list.contains("ws"));
|
||||||
|
assert!(list.contains("local"));
|
||||||
|
assert!(list.contains("wss://remote.example/mcp"));
|
||||||
|
|
||||||
|
let show = super::render_mcp_report_for(&loader, &workspace, Some("show alpha"))
|
||||||
|
.expect("mcp show report should render");
|
||||||
|
assert!(show.contains("Name alpha"));
|
||||||
|
assert!(show.contains("Command uvx"));
|
||||||
|
assert!(show.contains("Args alpha-server"));
|
||||||
|
assert!(show.contains("Env keys ALPHA_TOKEN"));
|
||||||
|
assert!(show.contains("Tool timeout 1200 ms"));
|
||||||
|
|
||||||
|
let remote = super::render_mcp_report_for(&loader, &workspace, Some("show remote"))
|
||||||
|
.expect("mcp show remote report should render");
|
||||||
|
assert!(remote.contains("Transport ws"));
|
||||||
|
assert!(remote.contains("URL wss://remote.example/mcp"));
|
||||||
|
|
||||||
|
let missing = super::render_mcp_report_for(&loader, &workspace, Some("show missing"))
|
||||||
|
.expect("missing report should render");
|
||||||
|
assert!(missing.contains("server `missing` is not configured"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(workspace);
|
||||||
|
let _ = fs::remove_dir_all(config_home);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_quoted_skill_frontmatter_values() {
|
fn parses_quoted_skill_frontmatter_values() {
|
||||||
let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";
|
let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ use api::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use commands::{
|
use commands::{
|
||||||
handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command,
|
handle_agents_slash_command, handle_mcp_slash_command, handle_plugins_slash_command,
|
||||||
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
|
handle_skills_slash_command, render_slash_command_help, resume_supported_slash_commands,
|
||||||
validate_slash_command_input, SlashCommand,
|
slash_command_specs, validate_slash_command_input, SlashCommand,
|
||||||
};
|
};
|
||||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||||
use init::initialize_repo;
|
use init::initialize_repo;
|
||||||
@@ -107,6 +107,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
CliAction::DumpManifests => dump_manifests(),
|
CliAction::DumpManifests => dump_manifests(),
|
||||||
CliAction::BootstrapPlan => print_bootstrap_plan(),
|
CliAction::BootstrapPlan => print_bootstrap_plan(),
|
||||||
CliAction::Agents { args } => LiveCli::print_agents(args.as_deref())?,
|
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::Skills { args } => LiveCli::print_skills(args.as_deref())?,
|
||||||
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
|
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
|
||||||
CliAction::Version => print_version(),
|
CliAction::Version => print_version(),
|
||||||
@@ -147,6 +148,9 @@ enum CliAction {
|
|||||||
Agents {
|
Agents {
|
||||||
args: Option<String>,
|
args: Option<String>,
|
||||||
},
|
},
|
||||||
|
Mcp {
|
||||||
|
args: Option<String>,
|
||||||
|
},
|
||||||
Skills {
|
Skills {
|
||||||
args: Option<String>,
|
args: Option<String>,
|
||||||
},
|
},
|
||||||
@@ -342,6 +346,9 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
"agents" => Ok(CliAction::Agents {
|
"agents" => Ok(CliAction::Agents {
|
||||||
args: join_optional_args(&rest[1..]),
|
args: join_optional_args(&rest[1..]),
|
||||||
}),
|
}),
|
||||||
|
"mcp" => Ok(CliAction::Mcp {
|
||||||
|
args: join_optional_args(&rest[1..]),
|
||||||
|
}),
|
||||||
"skills" => Ok(CliAction::Skills {
|
"skills" => Ok(CliAction::Skills {
|
||||||
args: join_optional_args(&rest[1..]),
|
args: join_optional_args(&rest[1..]),
|
||||||
}),
|
}),
|
||||||
@@ -400,6 +407,7 @@ fn bare_slash_command_guidance(command_name: &str) -> Option<String> {
|
|||||||
"dump-manifests"
|
"dump-manifests"
|
||||||
| "bootstrap-plan"
|
| "bootstrap-plan"
|
||||||
| "agents"
|
| "agents"
|
||||||
|
| "mcp"
|
||||||
| "skills"
|
| "skills"
|
||||||
| "system-prompt"
|
| "system-prompt"
|
||||||
| "login"
|
| "login"
|
||||||
@@ -435,6 +443,14 @@ fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
|
|||||||
match SlashCommand::parse(&raw) {
|
match SlashCommand::parse(&raw) {
|
||||||
Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help),
|
Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help),
|
||||||
Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { args }),
|
Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { args }),
|
||||||
|
Ok(Some(SlashCommand::Mcp { action, target })) => Ok(CliAction::Mcp {
|
||||||
|
args: match (action, target) {
|
||||||
|
(None, None) => None,
|
||||||
|
(Some(action), None) => Some(action),
|
||||||
|
(Some(action), Some(target)) => Some(format!("{action} {target}")),
|
||||||
|
(None, Some(target)) => Some(target),
|
||||||
|
},
|
||||||
|
}),
|
||||||
Ok(Some(SlashCommand::Skills { args })) => Ok(CliAction::Skills { args }),
|
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({
|
||||||
@@ -1353,6 +1369,19 @@ fn run_resume_command(
|
|||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(render_config_report(section.as_deref())?),
|
message: Some(render_config_report(section.as_deref())?),
|
||||||
}),
|
}),
|
||||||
|
SlashCommand::Mcp { action, target } => {
|
||||||
|
let cwd = env::current_dir()?;
|
||||||
|
let args = match (action.as_deref(), target.as_deref()) {
|
||||||
|
(None, None) => None,
|
||||||
|
(Some(action), None) => Some(action.to_string()),
|
||||||
|
(Some(action), Some(target)) => Some(format!("{action} {target}")),
|
||||||
|
(None, Some(target)) => Some(target.to_string()),
|
||||||
|
};
|
||||||
|
Ok(ResumeCommandOutcome {
|
||||||
|
session: session.clone(),
|
||||||
|
message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)?),
|
||||||
|
})
|
||||||
|
}
|
||||||
SlashCommand::Memory => Ok(ResumeCommandOutcome {
|
SlashCommand::Memory => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(render_memory_report()?),
|
message: Some(render_memory_report()?),
|
||||||
@@ -1870,6 +1899,16 @@ impl LiveCli {
|
|||||||
Self::print_config(section.as_deref())?;
|
Self::print_config(section.as_deref())?;
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
SlashCommand::Mcp { action, target } => {
|
||||||
|
let args = match (action.as_deref(), target.as_deref()) {
|
||||||
|
(None, None) => None,
|
||||||
|
(Some(action), None) => Some(action.to_string()),
|
||||||
|
(Some(action), Some(target)) => Some(format!("{action} {target}")),
|
||||||
|
(None, Some(target)) => Some(target.to_string()),
|
||||||
|
};
|
||||||
|
Self::print_mcp(args.as_deref())?;
|
||||||
|
false
|
||||||
|
}
|
||||||
SlashCommand::Memory => {
|
SlashCommand::Memory => {
|
||||||
Self::print_memory()?;
|
Self::print_memory()?;
|
||||||
false
|
false
|
||||||
@@ -2135,6 +2174,12 @@ impl LiveCli {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn print_mcp(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let cwd = env::current_dir()?;
|
||||||
|
println!("{}", handle_mcp_slash_command(args, &cwd)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn print_skills(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
fn print_skills(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
println!("{}", handle_skills_slash_command(args, &cwd)?);
|
println!("{}", handle_skills_slash_command(args, &cwd)?);
|
||||||
@@ -4192,6 +4237,9 @@ fn slash_command_completion_candidates_with_sessions(
|
|||||||
"/config hooks",
|
"/config hooks",
|
||||||
"/config model",
|
"/config model",
|
||||||
"/config plugins",
|
"/config plugins",
|
||||||
|
"/mcp ",
|
||||||
|
"/mcp list",
|
||||||
|
"/mcp show ",
|
||||||
"/export ",
|
"/export ",
|
||||||
"/issue ",
|
"/issue ",
|
||||||
"/model ",
|
"/model ",
|
||||||
@@ -4217,6 +4265,7 @@ fn slash_command_completion_candidates_with_sessions(
|
|||||||
"/teleport ",
|
"/teleport ",
|
||||||
"/ultraplan ",
|
"/ultraplan ",
|
||||||
"/agents help",
|
"/agents help",
|
||||||
|
"/mcp help",
|
||||||
"/skills help",
|
"/skills help",
|
||||||
] {
|
] {
|
||||||
completions.insert(candidate.to_string());
|
completions.insert(candidate.to_string());
|
||||||
@@ -4907,6 +4956,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|||||||
writeln!(out, " claw dump-manifests")?;
|
writeln!(out, " claw dump-manifests")?;
|
||||||
writeln!(out, " claw bootstrap-plan")?;
|
writeln!(out, " claw bootstrap-plan")?;
|
||||||
writeln!(out, " claw agents")?;
|
writeln!(out, " claw agents")?;
|
||||||
|
writeln!(out, " claw mcp")?;
|
||||||
writeln!(out, " claw skills")?;
|
writeln!(out, " claw skills")?;
|
||||||
writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
|
writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
|
||||||
writeln!(out, " claw login")?;
|
writeln!(out, " claw login")?;
|
||||||
@@ -4978,6 +5028,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|||||||
" claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt"
|
" claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt"
|
||||||
)?;
|
)?;
|
||||||
writeln!(out, " claw agents")?;
|
writeln!(out, " claw agents")?;
|
||||||
|
writeln!(out, " claw mcp show my-server")?;
|
||||||
writeln!(out, " claw /skills")?;
|
writeln!(out, " claw /skills")?;
|
||||||
writeln!(out, " claw login")?;
|
writeln!(out, " claw login")?;
|
||||||
writeln!(out, " claw init")?;
|
writeln!(out, " claw init")?;
|
||||||
@@ -5297,6 +5348,10 @@ mod tests {
|
|||||||
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 }
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&["mcp".to_string()]).expect("mcp should parse"),
|
||||||
|
CliAction::Mcp { args: None }
|
||||||
|
);
|
||||||
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 }
|
||||||
@@ -5356,11 +5411,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_direct_agents_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 }
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
);
|
||||||
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 }
|
||||||
@@ -5578,6 +5640,7 @@ mod tests {
|
|||||||
assert!(help.contains("/cost"));
|
assert!(help.contains("/cost"));
|
||||||
assert!(help.contains("/resume <session-path>"));
|
assert!(help.contains("/resume <session-path>"));
|
||||||
assert!(help.contains("/config [env|hooks|model|plugins]"));
|
assert!(help.contains("/config [env|hooks|model|plugins]"));
|
||||||
|
assert!(help.contains("/mcp [list|show <server>|help]"));
|
||||||
assert!(help.contains("/memory"));
|
assert!(help.contains("/memory"));
|
||||||
assert!(help.contains("/init"));
|
assert!(help.contains("/init"));
|
||||||
assert!(help.contains("/diff"));
|
assert!(help.contains("/diff"));
|
||||||
@@ -5608,6 +5671,7 @@ mod tests {
|
|||||||
assert!(completions.contains(&"/session list".to_string()));
|
assert!(completions.contains(&"/session list".to_string()));
|
||||||
assert!(completions.contains(&"/session switch session-current".to_string()));
|
assert!(completions.contains(&"/session switch session-current".to_string()));
|
||||||
assert!(completions.contains(&"/resume session-old".to_string()));
|
assert!(completions.contains(&"/resume session-old".to_string()));
|
||||||
|
assert!(completions.contains(&"/mcp list".to_string()));
|
||||||
assert!(completions.contains(&"/ultraplan ".to_string()));
|
assert!(completions.contains(&"/ultraplan ".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5644,7 +5708,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
names,
|
names,
|
||||||
vec![
|
vec![
|
||||||
"help", "status", "sandbox", "compact", "clear", "cost", "config", "memory",
|
"help", "status", "sandbox", "compact", "clear", "cost", "config", "mcp", "memory",
|
||||||
"init", "diff", "version", "export", "agents", "skills",
|
"init", "diff", "version", "export", "agents", "skills",
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -5717,6 +5781,7 @@ mod tests {
|
|||||||
assert!(help.contains("claw sandbox"));
|
assert!(help.contains("claw sandbox"));
|
||||||
assert!(help.contains("claw init"));
|
assert!(help.contains("claw init"));
|
||||||
assert!(help.contains("claw agents"));
|
assert!(help.contains("claw agents"));
|
||||||
|
assert!(help.contains("claw mcp"));
|
||||||
assert!(help.contains("claw skills"));
|
assert!(help.contains("claw skills"));
|
||||||
assert!(help.contains("claw /skills"));
|
assert!(help.contains("claw /skills"));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user