mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat(cli): add tool setup command + GitHub setup schema (#438)
* feat(cli): add `tool setup` command + GitHub setup schema - Add `ironclaw tool setup <name>` CLI command that reads `setup.required_secrets` from a tool's capabilities file and prompts the user for each secret, saving them to the encrypted secrets store. Handles already-configured secrets (ask to replace), optional secrets (skip on empty), and hidden input. - Add `setup.required_secrets` to GitHub tool capabilities file with `github_token` — the only WASM tool that was missing it after PR #437 added setup schemas to all other tools. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(cli): extract init_secrets_store helper + add tool name validation Address PR review feedback: - Extract duplicated secrets store initialization (~50 lines) from auth_tool and setup_tool into shared init_secrets_store() helper - Add validate_tool_name() to reject path traversal in tool names (applies to both auth_tool and setup_tool) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
98467a553e
commit
b987464f45
+182
-34
@@ -99,6 +99,20 @@ pub enum ToolCommand {
|
||||
#[arg(short, long, default_value = "default")]
|
||||
user: String,
|
||||
},
|
||||
|
||||
/// Configure required secrets for a tool (from setup.required_secrets)
|
||||
Setup {
|
||||
/// Name of the tool
|
||||
name: String,
|
||||
|
||||
/// Directory to look for tool (default: ~/.ironclaw/tools/)
|
||||
#[arg(short, long)]
|
||||
dir: Option<PathBuf>,
|
||||
|
||||
/// User ID for storing the secret (default: "default")
|
||||
#[arg(short, long, default_value = "default")]
|
||||
user: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Run a tool command.
|
||||
@@ -117,6 +131,7 @@ pub async fn run_tool_command(cmd: ToolCommand) -> anyhow::Result<()> {
|
||||
ToolCommand::Remove { name, dir } => remove_tool(name, dir).await,
|
||||
ToolCommand::Info { name_or_path, dir } => show_tool_info(name_or_path, dir).await,
|
||||
ToolCommand::Auth { name, dir, user } => auth_tool(name, dir, user).await,
|
||||
ToolCommand::Setup { name, dir, user } => setup_tool(name, dir, user).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,43 +538,24 @@ fn print_capabilities_detail(caps: &CapabilitiesFile) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure authentication for a tool.
|
||||
async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyhow::Result<()> {
|
||||
let tools_dir = dir.unwrap_or_else(default_tools_dir);
|
||||
let caps_path = tools_dir.join(format!("{}.capabilities.json", name));
|
||||
|
||||
if !caps_path.exists() {
|
||||
/// Validate a tool name to prevent path traversal.
|
||||
fn validate_tool_name(name: &str) -> anyhow::Result<()> {
|
||||
if name.is_empty()
|
||||
|| name.contains('/')
|
||||
|| name.contains('\\')
|
||||
|| name.contains("..")
|
||||
|| name.contains('\0')
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Tool '{}' not found or has no capabilities file at {}",
|
||||
name,
|
||||
caps_path.display()
|
||||
"Invalid tool name '{}': must not contain path separators or '..'",
|
||||
name
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Parse capabilities
|
||||
let content = fs::read_to_string(&caps_path).await?;
|
||||
let caps = CapabilitiesFile::from_json(&content)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid capabilities file: {}", e))?;
|
||||
|
||||
// Check for auth section
|
||||
let auth = caps.auth.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Tool '{}' has no auth configuration.\n\
|
||||
The tool may not require authentication, or auth setup is not defined.",
|
||||
name
|
||||
)
|
||||
})?;
|
||||
|
||||
let display_name = auth.display_name.as_deref().unwrap_or(&name);
|
||||
|
||||
let header = format!("{} Authentication", display_name);
|
||||
println!();
|
||||
println!("╔════════════════════════════════════════════════════════════════╗");
|
||||
println!("║ {:^62}║", header);
|
||||
println!("╚════════════════════════════════════════════════════════════════╝");
|
||||
println!();
|
||||
|
||||
// Initialize secrets store
|
||||
/// Initialize the secrets store from environment config.
|
||||
async fn init_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
|
||||
let config = Config::from_env().await?;
|
||||
let master_key = config.secrets.master_key().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
@@ -569,7 +565,7 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
|
||||
|
||||
let crypto = SecretsCrypto::new(master_key.clone())?;
|
||||
|
||||
let secrets_store: Arc<dyn SecretsStore + Send + Sync> = {
|
||||
let store: Arc<dyn SecretsStore + Send + Sync> = {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
let store = crate::history::Store::new(&config.database).await?;
|
||||
@@ -619,6 +615,47 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
|
||||
);
|
||||
}
|
||||
};
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// Configure authentication for a tool.
|
||||
async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyhow::Result<()> {
|
||||
validate_tool_name(&name)?;
|
||||
let tools_dir = dir.unwrap_or_else(default_tools_dir);
|
||||
let caps_path = tools_dir.join(format!("{}.capabilities.json", name));
|
||||
|
||||
if !caps_path.exists() {
|
||||
anyhow::bail!(
|
||||
"Tool '{}' not found or has no capabilities file at {}",
|
||||
name,
|
||||
caps_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Parse capabilities
|
||||
let content = fs::read_to_string(&caps_path).await?;
|
||||
let caps = CapabilitiesFile::from_json(&content)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid capabilities file: {}", e))?;
|
||||
|
||||
// Check for auth section
|
||||
let auth = caps.auth.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Tool '{}' has no auth configuration.\n\
|
||||
The tool may not require authentication, or auth setup is not defined.",
|
||||
name
|
||||
)
|
||||
})?;
|
||||
|
||||
let display_name = auth.display_name.as_deref().unwrap_or(&name);
|
||||
|
||||
let header = format!("{} Authentication", display_name);
|
||||
println!();
|
||||
println!("╔════════════════════════════════════════════════════════════════╗");
|
||||
println!("║ {:^62}║", header);
|
||||
println!("╚════════════════════════════════════════════════════════════════╝");
|
||||
println!();
|
||||
|
||||
let secrets_store = init_secrets_store().await?;
|
||||
|
||||
// Check if already configured
|
||||
let already_configured = secrets_store
|
||||
@@ -1159,6 +1196,117 @@ fn print_success(display_name: &str) {
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Configure required secrets for a tool via its `setup.required_secrets` schema.
|
||||
async fn setup_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyhow::Result<()> {
|
||||
validate_tool_name(&name)?;
|
||||
let tools_dir = dir.unwrap_or_else(default_tools_dir);
|
||||
let caps_path = tools_dir.join(format!("{}.capabilities.json", name));
|
||||
|
||||
if !caps_path.exists() {
|
||||
anyhow::bail!(
|
||||
"Tool '{}' not found or has no capabilities file at {}",
|
||||
name,
|
||||
caps_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&caps_path).await?;
|
||||
let caps = CapabilitiesFile::from_json(&content)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid capabilities file: {}", e))?;
|
||||
|
||||
let setup = caps.setup.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Tool '{}' has no setup configuration.\n\
|
||||
The tool may not require setup, or setup is not defined.\n\
|
||||
Try 'ironclaw tool auth {}' for OAuth-based authentication.",
|
||||
name,
|
||||
name
|
||||
)
|
||||
})?;
|
||||
|
||||
if setup.required_secrets.is_empty() {
|
||||
println!("Tool '{}' has no required secrets.", name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let display_name = caps
|
||||
.auth
|
||||
.as_ref()
|
||||
.and_then(|a| a.display_name.as_deref())
|
||||
.unwrap_or(&name);
|
||||
|
||||
println!();
|
||||
println!("╔════════════════════════════════════════════════════════════════╗");
|
||||
println!("║ {:^62}║", format!("{} Setup", display_name));
|
||||
println!("╚════════════════════════════════════════════════════════════════╝");
|
||||
println!();
|
||||
|
||||
let secrets_store = init_secrets_store().await?;
|
||||
|
||||
let mut any_saved = false;
|
||||
|
||||
for secret in &setup.required_secrets {
|
||||
let already_exists = secrets_store
|
||||
.exists(&user_id, &secret.name)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if already_exists {
|
||||
println!(" ✓ {} (already configured)", secret.prompt);
|
||||
|
||||
print!(" Replace? [y/N]: ");
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
|
||||
if !input.trim().eq_ignore_ascii_case("y") {
|
||||
continue;
|
||||
}
|
||||
print!(" {}: ", secret.prompt);
|
||||
} else if secret.optional {
|
||||
print!(" {} (optional, Enter to skip): ", secret.prompt);
|
||||
} else {
|
||||
print!(" {}: ", secret.prompt);
|
||||
}
|
||||
|
||||
std::io::stdout().flush()?;
|
||||
let value = read_hidden_input()?;
|
||||
println!();
|
||||
|
||||
if value.is_empty() {
|
||||
if secret.optional {
|
||||
println!(" Skipped.");
|
||||
} else {
|
||||
println!(
|
||||
" Warning: empty value for required secret '{}'.",
|
||||
secret.name
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let params = CreateSecretParams::new(&secret.name, &value).with_provider(name.to_string());
|
||||
secrets_store
|
||||
.create(&user_id, params)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save secret: {}", e))?;
|
||||
|
||||
println!(" ✓ Saved.");
|
||||
any_saved = true;
|
||||
}
|
||||
|
||||
println!();
|
||||
if any_saved {
|
||||
println!(" ✓ {} setup complete!", display_name);
|
||||
} else {
|
||||
println!(" No changes made.");
|
||||
}
|
||||
println!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -34,6 +34,14 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{
|
||||
"name": "github_token",
|
||||
"prompt": "GitHub Personal Access Token (from github.com/settings/tokens)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"default_limit": 30,
|
||||
"max_limit": 100
|
||||
|
||||
Reference in New Issue
Block a user