re-uploading work

This commit is contained in:
2026-02-04 17:46:30 -06:00
commit 3b14c65998
1388 changed files with 381262 additions and 0 deletions

View File

@@ -0,0 +1,521 @@
use anyhow::Result;
use clap::Subcommand;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::client::ApiClient;
use crate::config::CliConfig;
use crate::output::{self, OutputFormat};
#[derive(Subcommand)]
pub enum ActionCommands {
/// List all actions
List {
/// Filter by pack name
#[arg(long)]
pack: Option<String>,
/// Filter by action name
#[arg(short, long)]
name: Option<String>,
},
/// Show details of a specific action
Show {
/// Action reference (pack.action or ID)
action_ref: String,
},
/// Update an action
Update {
/// Action reference (pack.action or ID)
action_ref: String,
/// Update label
#[arg(long)]
label: Option<String>,
/// Update description
#[arg(long)]
description: Option<String>,
/// Update entrypoint
#[arg(long)]
entrypoint: Option<String>,
/// Update runtime ID
#[arg(long)]
runtime: Option<i64>,
},
/// Delete an action
Delete {
/// Action reference (pack.action or ID)
action_ref: String,
/// Skip confirmation prompt
#[arg(short, long)]
yes: bool,
},
/// Execute an action
Execute {
/// Action reference (pack.action or ID)
action_ref: String,
/// Action parameters in key=value format
#[arg(long)]
param: Vec<String>,
/// Parameters as JSON string
#[arg(long, conflicts_with = "param")]
params_json: Option<String>,
/// Wait for execution to complete
#[arg(short, long)]
wait: bool,
/// Timeout in seconds when waiting (default: 300)
#[arg(long, default_value = "300", requires = "wait")]
timeout: u64,
},
}
#[derive(Debug, Serialize, Deserialize)]
struct Action {
id: i64,
#[serde(rename = "ref")]
action_ref: String,
pack_ref: String,
label: String,
description: String,
entrypoint: String,
runtime: Option<i64>,
created: String,
updated: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct ActionDetail {
id: i64,
#[serde(rename = "ref")]
action_ref: String,
pack: i64,
pack_ref: String,
label: String,
description: String,
entrypoint: String,
runtime: Option<i64>,
param_schema: Option<serde_json::Value>,
out_schema: Option<serde_json::Value>,
created: String,
updated: String,
}
#[derive(Debug, Serialize)]
struct UpdateActionRequest {
#[serde(skip_serializing_if = "Option::is_none")]
label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
entrypoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
runtime: Option<i64>,
}
#[derive(Debug, Serialize)]
struct ExecuteActionRequest {
action_ref: String,
parameters: serde_json::Value,
}
#[derive(Debug, Serialize, Deserialize)]
struct Execution {
id: i64,
action: Option<i64>,
action_ref: String,
config: Option<serde_json::Value>,
parent: Option<i64>,
enforcement: Option<i64>,
executor: Option<i64>,
status: String,
result: Option<serde_json::Value>,
created: String,
updated: String,
}
pub async fn handle_action_command(
profile: &Option<String>,
command: ActionCommands,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
match command {
ActionCommands::List { pack, name } => {
handle_list(pack, name, profile, api_url, output_format).await
}
ActionCommands::Show { action_ref } => {
handle_show(action_ref, profile, api_url, output_format).await
}
ActionCommands::Update {
action_ref,
label,
description,
entrypoint,
runtime,
} => {
handle_update(
action_ref,
label,
description,
entrypoint,
runtime,
profile,
api_url,
output_format,
)
.await
}
ActionCommands::Delete { action_ref, yes } => {
handle_delete(action_ref, yes, profile, api_url, output_format).await
}
ActionCommands::Execute {
action_ref,
param,
params_json,
wait,
timeout,
} => {
handle_execute(
action_ref,
param,
params_json,
profile,
api_url,
wait,
timeout,
output_format,
)
.await
}
}
}
async fn handle_list(
pack: Option<String>,
name: Option<String>,
profile: &Option<String>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
// Use pack-specific endpoint if pack filter is specified
let path = if let Some(pack_ref) = pack {
format!("/packs/{}/actions", pack_ref)
} else {
"/actions".to_string()
};
let mut actions: Vec<Action> = client.get(&path).await?;
// Filter by name if specified (client-side filtering)
if let Some(action_name) = name {
actions.retain(|a| a.action_ref.contains(&action_name));
}
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&actions, output_format)?;
}
OutputFormat::Table => {
if actions.is_empty() {
output::print_info("No actions found");
} else {
let mut table = output::create_table();
output::add_header(
&mut table,
vec!["ID", "Pack", "Name", "Runner", "Enabled", "Description"],
);
for action in actions {
table.add_row(vec![
action.id.to_string(),
action.pack_ref.clone(),
action.action_ref.clone(),
action
.runtime
.map(|r| r.to_string())
.unwrap_or_else(|| "none".to_string()),
"".to_string(),
output::truncate(&action.description, 40),
]);
}
println!("{}", table);
}
}
}
Ok(())
}
async fn handle_show(
action_ref: String,
profile: &Option<String>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let path = format!("/actions/{}", action_ref);
let action: ActionDetail = client.get(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&action, output_format)?;
}
OutputFormat::Table => {
output::print_section(&format!("Action: {}", action.action_ref));
output::print_key_value_table(vec![
("ID", action.id.to_string()),
("Reference", action.action_ref.clone()),
("Pack", action.pack_ref.clone()),
("Label", action.label.clone()),
("Description", action.description.clone()),
("Entry Point", action.entrypoint.clone()),
(
"Runtime",
action
.runtime
.map(|r| r.to_string())
.unwrap_or_else(|| "None".to_string()),
),
("Created", output::format_timestamp(&action.created)),
("Updated", output::format_timestamp(&action.updated)),
]);
if let Some(params) = action.param_schema {
if !params.is_null() {
output::print_section("Parameters Schema");
println!("{}", serde_json::to_string_pretty(&params)?);
}
}
}
}
Ok(())
}
async fn handle_update(
action_ref: String,
label: Option<String>,
description: Option<String>,
entrypoint: Option<String>,
runtime: Option<i64>,
profile: &Option<String>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
// Check that at least one field is provided
if label.is_none() && description.is_none() && entrypoint.is_none() && runtime.is_none() {
anyhow::bail!("At least one field must be provided to update");
}
let request = UpdateActionRequest {
label,
description,
entrypoint,
runtime,
};
let path = format!("/actions/{}", action_ref);
let action: ActionDetail = client.put(&path, &request).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&action, output_format)?;
}
OutputFormat::Table => {
output::print_success(&format!(
"Action '{}' updated successfully",
action.action_ref
));
output::print_key_value_table(vec![
("ID", action.id.to_string()),
("Ref", action.action_ref.clone()),
("Pack", action.pack_ref.clone()),
("Label", action.label.clone()),
("Description", action.description.clone()),
("Entrypoint", action.entrypoint.clone()),
(
"Runtime",
action
.runtime
.map(|r| r.to_string())
.unwrap_or_else(|| "None".to_string()),
),
("Updated", output::format_timestamp(&action.updated)),
]);
}
}
Ok(())
}
async fn handle_delete(
action_ref: String,
yes: bool,
profile: &Option<String>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
// Confirm deletion unless --yes is provided
if !yes && matches!(output_format, OutputFormat::Table) {
let confirm = dialoguer::Confirm::new()
.with_prompt(format!(
"Are you sure you want to delete action '{}'?",
action_ref
))
.default(false)
.interact()?;
if !confirm {
output::print_info("Delete cancelled");
return Ok(());
}
}
let path = format!("/actions/{}", action_ref);
client.delete_no_response(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
let msg = serde_json::json!({"message": "Action deleted successfully"});
output::print_output(&msg, output_format)?;
}
OutputFormat::Table => {
output::print_success(&format!("Action '{}' deleted successfully", action_ref));
}
}
Ok(())
}
async fn handle_execute(
action_ref: String,
params: Vec<String>,
params_json: Option<String>,
profile: &Option<String>,
api_url: &Option<String>,
wait: bool,
timeout: u64,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
// Parse parameters
let parameters = if let Some(json_str) = params_json {
serde_json::from_str(&json_str)?
} else if !params.is_empty() {
let mut map = HashMap::new();
for p in params {
let parts: Vec<&str> = p.splitn(2, '=').collect();
if parts.len() != 2 {
anyhow::bail!("Invalid parameter format: '{}'. Expected key=value", p);
}
// Try to parse as JSON value, fall back to string
let value: serde_json::Value = serde_json::from_str(parts[1])
.unwrap_or_else(|_| serde_json::Value::String(parts[1].to_string()));
map.insert(parts[0].to_string(), value);
}
serde_json::to_value(map)?
} else {
serde_json::json!({})
};
let request = ExecuteActionRequest {
action_ref: action_ref.clone(),
parameters,
};
match output_format {
OutputFormat::Table => {
output::print_info(&format!("Executing action: {}", action_ref));
}
_ => {}
}
let path = "/executions/execute".to_string();
let mut execution: Execution = client.post(&path, &request).await?;
if wait {
match output_format {
OutputFormat::Table => {
output::print_info(&format!(
"Waiting for execution {} to complete...",
execution.id
));
}
_ => {}
}
// Poll for completion
let start = std::time::Instant::now();
let timeout_duration = std::time::Duration::from_secs(timeout);
loop {
if start.elapsed() > timeout_duration {
anyhow::bail!("Execution timed out after {} seconds", timeout);
}
let exec_path = format!("/executions/{}", execution.id);
execution = client.get(&exec_path).await?;
if execution.status == "succeeded"
|| execution.status == "failed"
|| execution.status == "canceled"
{
break;
}
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}
}
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&execution, output_format)?;
}
OutputFormat::Table => {
output::print_success(&format!(
"Execution {} {}",
execution.id,
if wait { "completed" } else { "started" }
));
output::print_section("Execution Details");
output::print_key_value_table(vec![
("Execution ID", execution.id.to_string()),
("Action", execution.action_ref.clone()),
("Status", output::format_status(&execution.status)),
("Created", output::format_timestamp(&execution.created)),
("Updated", output::format_timestamp(&execution.updated)),
]);
if let Some(result) = execution.result {
if !result.is_null() {
output::print_section("Result");
println!("{}", serde_json::to_string_pretty(&result)?);
}
}
}
}
Ok(())
}

View File

@@ -0,0 +1,213 @@
use anyhow::Result;
use clap::Subcommand;
use serde::{Deserialize, Serialize};
use crate::client::ApiClient;
use crate::config::CliConfig;
use crate::output::{self, OutputFormat};
#[derive(Subcommand)]
pub enum AuthCommands {
/// Log in to Attune API
Login {
/// Username or email
#[arg(short, long)]
username: String,
/// Password (will prompt if not provided)
#[arg(long)]
password: Option<String>,
},
/// Log out and clear authentication tokens
Logout,
/// Show current authentication status
Whoami,
/// Refresh authentication token
Refresh,
}
#[derive(Debug, Serialize, Deserialize)]
struct LoginRequest {
login: String,
password: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct LoginResponse {
access_token: String,
refresh_token: String,
expires_in: i64,
}
#[derive(Debug, Serialize, Deserialize)]
struct Identity {
id: i64,
login: String,
display_name: Option<String>,
}
pub async fn handle_auth_command(
profile: &Option<String>,
command: AuthCommands,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
match command {
AuthCommands::Login { username, password } => {
handle_login(username, password, profile, api_url, output_format).await
}
AuthCommands::Logout => handle_logout(profile, output_format).await,
AuthCommands::Whoami => handle_whoami(profile, api_url, output_format).await,
AuthCommands::Refresh => handle_refresh(profile, api_url, output_format).await,
}
}
async fn handle_login(
username: String,
password: Option<String>,
profile: &Option<String>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
// Prompt for password if not provided
let password = match password {
Some(p) => p,
None => {
let pw = dialoguer::Password::new()
.with_prompt("Password")
.interact()?;
pw
}
};
let mut client = ApiClient::from_config(&config, api_url);
let login_req = LoginRequest {
login: username,
password,
};
let response: LoginResponse = client.post("/auth/login", &login_req).await?;
// Save tokens to config
let mut config = CliConfig::load()?;
config.set_auth(
response.access_token.clone(),
response.refresh_token.clone(),
)?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&response, output_format)?;
}
OutputFormat::Table => {
output::print_success("Successfully logged in");
output::print_info(&format!("Token expires in {} seconds", response.expires_in));
}
}
Ok(())
}
async fn handle_logout(profile: &Option<String>, output_format: OutputFormat) -> Result<()> {
let mut config = CliConfig::load_with_profile(profile.as_deref())?;
config.clear_auth()?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
let msg = serde_json::json!({"message": "Successfully logged out"});
output::print_output(&msg, output_format)?;
}
OutputFormat::Table => {
output::print_success("Successfully logged out");
}
}
Ok(())
}
async fn handle_whoami(
profile: &Option<String>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
if config.auth_token().ok().flatten().is_none() {
anyhow::bail!("Not logged in. Use 'attune auth login' to authenticate.");
}
let mut client = ApiClient::from_config(&config, api_url);
let identity: Identity = client.get("/auth/me").await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&identity, output_format)?;
}
OutputFormat::Table => {
output::print_section("Current Identity");
output::print_key_value_table(vec![
("ID", identity.id.to_string()),
("Login", identity.login),
(
"Display Name",
identity.display_name.unwrap_or_else(|| "-".to_string()),
),
]);
}
}
Ok(())
}
async fn handle_refresh(
profile: &Option<String>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
// Check if we have a refresh token
let refresh_token = config
.refresh_token()
.ok()
.flatten()
.ok_or_else(|| anyhow::anyhow!("No refresh token found. Please log in again."))?;
let mut client = ApiClient::from_config(&config, api_url);
#[derive(Serialize)]
struct RefreshRequest {
refresh_token: String,
}
// Call the refresh endpoint
let response: LoginResponse = client
.post("/auth/refresh", &RefreshRequest { refresh_token })
.await?;
// Save new tokens to config
let mut config = CliConfig::load()?;
config.set_auth(
response.access_token.clone(),
response.refresh_token.clone(),
)?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&response, output_format)?;
}
OutputFormat::Table => {
output::print_success("Token refreshed successfully");
output::print_info(&format!(
"New token expires in {} seconds",
response.expires_in
));
}
}
Ok(())
}

View File

@@ -0,0 +1,354 @@
use anyhow::{Context, Result};
use clap::Subcommand;
use colored::Colorize;
use crate::config::CliConfig;
use crate::output::{self, OutputFormat};
#[derive(Subcommand)]
pub enum ConfigCommands {
/// List all configuration values
List,
/// Get a configuration value
Get {
/// Configuration key
key: String,
},
/// Set a configuration value
Set {
/// Configuration key
key: String,
/// Configuration value
value: String,
},
/// Show the configuration file path
Path,
/// List all profiles
Profiles,
/// Show current profile
Current,
/// Switch to a different profile
Use {
/// Profile name
name: String,
},
/// Add or update a profile
AddProfile {
/// Profile name
name: String,
/// API URL
#[arg(short, long)]
api_url: String,
/// Description
#[arg(short, long)]
description: Option<String>,
},
/// Remove a profile
RemoveProfile {
/// Profile name
name: String,
},
/// Show profile details
ShowProfile {
/// Profile name
name: String,
},
}
pub async fn handle_config_command(
_profile: &Option<String>,
command: ConfigCommands,
output_format: OutputFormat,
) -> Result<()> {
match command {
ConfigCommands::List => handle_list(output_format).await,
ConfigCommands::Get { key } => handle_get(key, output_format).await,
ConfigCommands::Set { key, value } => handle_set(key, value, output_format).await,
ConfigCommands::Path => handle_path(output_format).await,
ConfigCommands::Profiles => handle_profiles(output_format).await,
ConfigCommands::Current => handle_current(output_format).await,
ConfigCommands::Use { name } => handle_use(name, output_format).await,
ConfigCommands::AddProfile {
name,
api_url,
description,
} => handle_add_profile(name, api_url, description, output_format).await,
ConfigCommands::RemoveProfile { name } => handle_remove_profile(name, output_format).await,
ConfigCommands::ShowProfile { name } => handle_show_profile(name, output_format).await,
}
}
async fn handle_list(output_format: OutputFormat) -> Result<()> {
let config = CliConfig::load()?; // Config commands always use default profile
let all_config = config.list_all();
match output_format {
OutputFormat::Json => {
let map: std::collections::HashMap<String, String> = all_config.into_iter().collect();
output::print_output(&map, output_format)?;
}
OutputFormat::Yaml => {
let map: std::collections::HashMap<String, String> = all_config.into_iter().collect();
output::print_output(&map, output_format)?;
}
OutputFormat::Table => {
output::print_section("Configuration");
let pairs: Vec<(&str, String)> = all_config
.iter()
.map(|(k, v)| (k.as_str(), v.clone()))
.collect();
output::print_key_value_table(pairs);
}
}
Ok(())
}
async fn handle_get(key: String, output_format: OutputFormat) -> Result<()> {
let config = CliConfig::load()?; // Config commands always use default profile
let value = config.get_value(&key)?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
let result = serde_json::json!({
"key": key,
"value": value
});
output::print_output(&result, output_format)?;
}
OutputFormat::Table => {
println!("{}", value);
}
}
Ok(())
}
async fn handle_profiles(output_format: OutputFormat) -> Result<()> {
let config = CliConfig::load()?; // Config commands always use default profile
let profiles = config.list_profiles();
let current = &config.current_profile;
match output_format {
OutputFormat::Json => {
let data: Vec<_> = profiles
.iter()
.map(|name| {
serde_json::json!({
"name": name,
"current": name == current
})
})
.collect();
output::print_output(&data, output_format)?;
}
OutputFormat::Yaml => {
let data: Vec<_> = profiles
.iter()
.map(|name| {
serde_json::json!({
"name": name,
"current": name == current
})
})
.collect();
output::print_output(&data, output_format)?;
}
OutputFormat::Table => {
output::print_section("Profiles");
for name in profiles {
if name == *current {
println!("{} (active)", name.bright_green().bold());
} else {
println!("{}", name);
}
}
}
}
Ok(())
}
async fn handle_current(output_format: OutputFormat) -> Result<()> {
let config = CliConfig::load()?; // Config commands always use default profile
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
let result = serde_json::json!({
"current_profile": config.current_profile
});
output::print_output(&result, output_format)?;
}
OutputFormat::Table => {
println!("{}", config.current_profile);
}
}
Ok(())
}
async fn handle_use(name: String, output_format: OutputFormat) -> Result<()> {
let mut config = CliConfig::load()?;
config.switch_profile(name.clone())?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
let result = serde_json::json!({
"current_profile": name,
"message": "Switched profile"
});
output::print_output(&result, output_format)?;
}
OutputFormat::Table => {
output::print_success(&format!("Switched to profile '{}'", name));
}
}
Ok(())
}
async fn handle_add_profile(
name: String,
api_url: String,
description: Option<String>,
output_format: OutputFormat,
) -> Result<()> {
use crate::config::Profile;
let mut config = CliConfig::load()?;
let profile = Profile {
api_url: api_url.clone(),
auth_token: None,
refresh_token: None,
output_format: None,
description,
};
config.set_profile(name.clone(), profile)?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
let result = serde_json::json!({
"profile": name,
"api_url": api_url,
"message": "Profile added"
});
output::print_output(&result, output_format)?;
}
OutputFormat::Table => {
output::print_success(&format!("Profile '{}' added", name));
output::print_info(&format!("API URL: {}", api_url));
}
}
Ok(())
}
async fn handle_remove_profile(name: String, output_format: OutputFormat) -> Result<()> {
let mut config = CliConfig::load()?;
config.remove_profile(&name)?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
let result = serde_json::json!({
"profile": name,
"message": "Profile removed"
});
output::print_output(&result, output_format)?;
}
OutputFormat::Table => {
output::print_success(&format!("Profile '{}' removed", name));
}
}
Ok(())
}
async fn handle_show_profile(name: String, output_format: OutputFormat) -> Result<()> {
let config = CliConfig::load()?; // Config commands always use default profile
let profile = config
.get_profile(&name)
.context(format!("Profile '{}' not found", name))?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&profile, output_format)?;
}
OutputFormat::Table => {
output::print_section(&format!("Profile: {}", name));
let mut pairs = vec![
("API URL", profile.api_url.clone()),
(
"Auth Token",
profile
.auth_token
.as_ref()
.map(|_| "***")
.unwrap_or("(not set)")
.to_string(),
),
(
"Refresh Token",
profile
.refresh_token
.as_ref()
.map(|_| "***")
.unwrap_or("(not set)")
.to_string(),
),
];
if let Some(output_format) = &profile.output_format {
pairs.push(("Output Format", output_format.clone()));
}
if let Some(description) = &profile.description {
pairs.push(("Description", description.clone()));
}
output::print_key_value_table(pairs);
}
}
Ok(())
}
async fn handle_set(key: String, value: String, output_format: OutputFormat) -> Result<()> {
let mut config = CliConfig::load()?;
config.set_value(&key, value.clone())?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
let result = serde_json::json!({
"key": key,
"value": value,
"message": "Configuration updated"
});
output::print_output(&result, output_format)?;
}
OutputFormat::Table => {
println!("Configuration updated: {} = {}", key, value);
}
}
Ok(())
}
async fn handle_path(output_format: OutputFormat) -> Result<()> {
let path = CliConfig::config_path()?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
let result = serde_json::json!({
"path": path.to_string_lossy()
});
output::print_output(&result, output_format)?;
}
OutputFormat::Table => {
println!("{}", path.display());
}
}
Ok(())
}

View File

@@ -0,0 +1,445 @@
use anyhow::Result;
use clap::Subcommand;
use serde::{Deserialize, Serialize};
use crate::client::ApiClient;
use crate::config::CliConfig;
use crate::output::{self, OutputFormat};
#[derive(Subcommand)]
pub enum ExecutionCommands {
/// List all executions
List {
/// Filter by pack name
#[arg(long)]
pack: Option<String>,
/// Filter by action name
#[arg(short, long)]
action: Option<String>,
/// Filter by status
#[arg(short, long)]
status: Option<String>,
/// Search in execution result (case-insensitive)
#[arg(short, long)]
result: Option<String>,
/// Limit number of results
#[arg(short, long, default_value = "50")]
limit: i32,
},
/// Show details of a specific execution
Show {
/// Execution ID
execution_id: i64,
},
/// Show execution logs
Logs {
/// Execution ID
execution_id: i64,
/// Follow log output
#[arg(short, long)]
follow: bool,
},
/// Cancel a running execution
Cancel {
/// Execution ID
execution_id: i64,
/// Skip confirmation prompt
#[arg(short = 'y', long)]
yes: bool,
},
/// Get raw execution result
Result {
/// Execution ID
execution_id: i64,
/// Output format (json or yaml, default: json)
#[arg(short = 'f', long, value_enum, default_value = "json")]
format: ResultFormat,
},
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum ResultFormat {
Json,
Yaml,
}
#[derive(Debug, Serialize, Deserialize)]
struct Execution {
id: i64,
action_ref: String,
status: String,
#[serde(default)]
parent: Option<i64>,
#[serde(default)]
enforcement: Option<i64>,
#[serde(default)]
result: Option<serde_json::Value>,
created: String,
#[serde(default)]
updated: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct ExecutionDetail {
id: i64,
#[serde(default)]
action: Option<i64>,
action_ref: String,
#[serde(default)]
config: Option<serde_json::Value>,
status: String,
#[serde(default)]
result: Option<serde_json::Value>,
#[serde(default)]
parent: Option<i64>,
#[serde(default)]
enforcement: Option<i64>,
#[serde(default)]
executor: Option<i64>,
created: String,
updated: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct ExecutionLogs {
execution_id: i64,
logs: Vec<LogEntry>,
}
#[derive(Debug, Serialize, Deserialize)]
struct LogEntry {
timestamp: String,
level: String,
message: String,
}
pub async fn handle_execution_command(
profile: &Option<String>,
command: ExecutionCommands,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
match command {
ExecutionCommands::List {
pack,
action,
status,
result,
limit,
} => {
handle_list(
profile,
pack,
action,
status,
result,
limit,
api_url,
output_format,
)
.await
}
ExecutionCommands::Show { execution_id } => {
handle_show(profile, execution_id, api_url, output_format).await
}
ExecutionCommands::Logs {
execution_id,
follow,
} => handle_logs(profile, execution_id, follow, api_url, output_format).await,
ExecutionCommands::Cancel { execution_id, yes } => {
handle_cancel(profile, execution_id, yes, api_url, output_format).await
}
ExecutionCommands::Result {
execution_id,
format,
} => handle_result(profile, execution_id, format, api_url).await,
}
}
async fn handle_list(
profile: &Option<String>,
pack: Option<String>,
action: Option<String>,
status: Option<String>,
result: Option<String>,
limit: i32,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let mut query_params = vec![format!("per_page={}", limit)];
if let Some(pack_name) = pack {
query_params.push(format!("pack_name={}", pack_name));
}
if let Some(action_name) = action {
query_params.push(format!("action_ref={}", action_name));
}
if let Some(status_filter) = status {
query_params.push(format!("status={}", status_filter));
}
if let Some(result_search) = result {
query_params.push(format!(
"result_contains={}",
urlencoding::encode(&result_search)
));
}
let path = format!("/executions?{}", query_params.join("&"));
let executions: Vec<Execution> = client.get(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&executions, output_format)?;
}
OutputFormat::Table => {
if executions.is_empty() {
output::print_info("No executions found");
} else {
let mut table = output::create_table();
output::add_header(
&mut table,
vec!["ID", "Action", "Status", "Started", "Duration"],
);
for execution in executions {
table.add_row(vec![
execution.id.to_string(),
execution.action_ref.clone(),
output::format_status(&execution.status),
output::format_timestamp(&execution.created),
"-".to_string(),
]);
}
println!("{}", table);
}
}
}
Ok(())
}
async fn handle_show(
profile: &Option<String>,
execution_id: i64,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let path = format!("/executions/{}", execution_id);
let execution: ExecutionDetail = client.get(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&execution, output_format)?;
}
OutputFormat::Table => {
output::print_section(&format!("Execution: {}", execution.id));
output::print_key_value_table(vec![
("ID", execution.id.to_string()),
("Action", execution.action_ref.clone()),
("Status", output::format_status(&execution.status)),
(
"Parent ID",
execution
.parent
.map(|id| id.to_string())
.unwrap_or_else(|| "None".to_string()),
),
(
"Enforcement ID",
execution
.enforcement
.map(|id| id.to_string())
.unwrap_or_else(|| "None".to_string()),
),
(
"Executor ID",
execution
.executor
.map(|id| id.to_string())
.unwrap_or_else(|| "None".to_string()),
),
("Created", output::format_timestamp(&execution.created)),
("Updated", output::format_timestamp(&execution.updated)),
]);
if let Some(config) = execution.config {
if !config.is_null() {
output::print_section("Configuration");
println!("{}", serde_json::to_string_pretty(&config)?);
}
}
if let Some(result) = execution.result {
if !result.is_null() {
output::print_section("Result");
println!("{}", serde_json::to_string_pretty(&result)?);
}
}
}
}
Ok(())
}
async fn handle_logs(
profile: &Option<String>,
execution_id: i64,
follow: bool,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let path = format!("/executions/{}/logs", execution_id);
if follow {
// Polling implementation for following logs
let mut last_count = 0;
loop {
let logs: ExecutionLogs = client.get(&path).await?;
// Print new logs only
for log in logs.logs.iter().skip(last_count) {
match output_format {
OutputFormat::Json => {
println!("{}", serde_json::to_string(log)?);
}
OutputFormat::Yaml => {
println!("{}", serde_yaml_ng::to_string(log)?);
}
OutputFormat::Table => {
println!(
"[{}] [{}] {}",
output::format_timestamp(&log.timestamp),
log.level.to_uppercase(),
log.message
);
}
}
}
last_count = logs.logs.len();
// Check if execution is complete
let exec_path = format!("/executions/{}", execution_id);
let execution: ExecutionDetail = client.get(&exec_path).await?;
let status_lower = execution.status.to_lowercase();
if status_lower == "succeeded" || status_lower == "failed" || status_lower == "canceled"
{
break;
}
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}
} else {
let logs: ExecutionLogs = client.get(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&logs, output_format)?;
}
OutputFormat::Table => {
if logs.logs.is_empty() {
output::print_info("No logs available");
} else {
for log in logs.logs {
println!(
"[{}] [{}] {}",
output::format_timestamp(&log.timestamp),
log.level.to_uppercase(),
log.message
);
}
}
}
}
}
Ok(())
}
async fn handle_result(
profile: &Option<String>,
execution_id: i64,
format: ResultFormat,
api_url: &Option<String>,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let path = format!("/executions/{}", execution_id);
let execution: ExecutionDetail = client.get(&path).await?;
// Check if execution has a result
if let Some(result) = execution.result {
// Output raw result in requested format
match format {
ResultFormat::Json => {
println!("{}", serde_json::to_string_pretty(&result)?);
}
ResultFormat::Yaml => {
println!("{}", serde_yaml_ng::to_string(&result)?);
}
}
} else {
anyhow::bail!("Execution {} has no result yet", execution_id);
}
Ok(())
}
async fn handle_cancel(
profile: &Option<String>,
execution_id: i64,
yes: bool,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
// Confirm cancellation unless --yes is provided
if !yes && matches!(output_format, OutputFormat::Table) {
let confirm = dialoguer::Confirm::new()
.with_prompt(format!(
"Are you sure you want to cancel execution {}?",
execution_id
))
.default(false)
.interact()?;
if !confirm {
output::print_info("Cancellation aborted");
return Ok(());
}
}
let path = format!("/executions/{}/cancel", execution_id);
let execution: ExecutionDetail = client.post(&path, &serde_json::json!({})).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&execution, output_format)?;
}
OutputFormat::Table => {
output::print_success(&format!("Execution {} cancelled", execution_id));
}
}
Ok(())
}

View File

@@ -0,0 +1,9 @@
pub mod action;
pub mod auth;
pub mod config;
pub mod execution;
pub mod pack;
pub mod pack_index;
pub mod rule;
pub mod sensor;
pub mod trigger;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,387 @@
//! Pack registry index management utilities
use crate::output::{self, OutputFormat};
use anyhow::Result;
use attune_common::pack_registry::calculate_directory_checksum;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
/// Update a registry index file with a new pack entry
pub async fn handle_index_update(
index_path: String,
pack_path: String,
git_url: Option<String>,
git_ref: Option<String>,
archive_url: Option<String>,
update: bool,
output_format: OutputFormat,
) -> Result<()> {
// Load existing index
let index_file_path = Path::new(&index_path);
if !index_file_path.exists() {
return Err(anyhow::anyhow!("Index file not found: {}", index_path));
}
let index_content = fs::read_to_string(index_file_path)?;
let mut index: JsonValue = serde_json::from_str(&index_content)?;
// Get packs array (or create it)
let packs = index
.get_mut("packs")
.and_then(|p| p.as_array_mut())
.ok_or_else(|| anyhow::anyhow!("Invalid index format: missing 'packs' array"))?;
// Load pack.yaml from the pack directory
let pack_dir = Path::new(&pack_path);
if !pack_dir.exists() || !pack_dir.is_dir() {
return Err(anyhow::anyhow!("Pack directory not found: {}", pack_path));
}
let pack_yaml_path = pack_dir.join("pack.yaml");
if !pack_yaml_path.exists() {
return Err(anyhow::anyhow!(
"pack.yaml not found in directory: {}",
pack_path
));
}
let pack_yaml_content = fs::read_to_string(&pack_yaml_path)?;
let pack_yaml: serde_yaml_ng::Value = serde_yaml_ng::from_str(&pack_yaml_content)?;
// Extract pack metadata
let pack_ref = pack_yaml
.get("ref")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'ref' field in pack.yaml"))?;
let version = pack_yaml
.get("version")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'version' field in pack.yaml"))?;
// Check if pack already exists in index
let existing_index = packs
.iter()
.position(|p| p.get("ref").and_then(|r| r.as_str()) == Some(pack_ref));
if let Some(_idx) = existing_index {
if !update {
return Err(anyhow::anyhow!(
"Pack '{}' already exists in index. Use --update to replace it.",
pack_ref
));
}
if output_format == OutputFormat::Table {
output::print_info(&format!("Updating existing entry for '{}'", pack_ref));
}
} else {
if output_format == OutputFormat::Table {
output::print_info(&format!("Adding new entry for '{}'", pack_ref));
}
}
// Calculate checksum
if output_format == OutputFormat::Table {
output::print_info("Calculating checksum...");
}
let checksum = calculate_directory_checksum(pack_dir)?;
// Build install sources
let mut install_sources = Vec::new();
if let Some(ref git) = git_url {
let default_ref = format!("v{}", version);
let ref_value = git_ref.as_ref().map(|s| s.as_str()).unwrap_or(&default_ref);
install_sources.push(serde_json::json!({
"type": "git",
"url": git,
"ref": ref_value,
"checksum": format!("sha256:{}", checksum)
}));
}
if let Some(ref archive) = archive_url {
install_sources.push(serde_json::json!({
"type": "archive",
"url": archive,
"checksum": format!("sha256:{}", checksum)
}));
}
// Extract other metadata
let label = pack_yaml
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(pack_ref);
let description = pack_yaml
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("");
let author = pack_yaml
.get("author")
.and_then(|v| v.as_str())
.unwrap_or("Unknown");
let license = pack_yaml
.get("license")
.and_then(|v| v.as_str())
.unwrap_or("Apache-2.0");
let email = pack_yaml.get("email").and_then(|v| v.as_str());
let homepage = pack_yaml.get("homepage").and_then(|v| v.as_str());
let repository = pack_yaml.get("repository").and_then(|v| v.as_str());
let keywords: Vec<String> = pack_yaml
.get("keywords")
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let runtime_deps: Vec<String> = pack_yaml
.get("dependencies")
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
// Count components
let actions_count = pack_yaml["actions"]
.as_mapping()
.map(|m| m.len())
.unwrap_or(0);
let sensors_count = pack_yaml["sensors"]
.as_mapping()
.map(|m| m.len())
.unwrap_or(0);
let triggers_count = pack_yaml["triggers"]
.as_mapping()
.map(|m| m.len())
.unwrap_or(0);
// Build index entry
let mut index_entry = serde_json::json!({
"ref": pack_ref,
"label": label,
"description": description,
"version": version,
"author": author,
"license": license,
"keywords": keywords,
"runtime_deps": runtime_deps,
"install_sources": install_sources,
"contents": {
"actions": actions_count,
"sensors": sensors_count,
"triggers": triggers_count,
"rules": 0,
"workflows": 0
}
});
// Add optional fields
if let Some(e) = email {
index_entry["email"] = JsonValue::String(e.to_string());
}
if let Some(h) = homepage {
index_entry["homepage"] = JsonValue::String(h.to_string());
}
if let Some(r) = repository {
index_entry["repository"] = JsonValue::String(r.to_string());
}
// Update or add entry
if let Some(idx) = existing_index {
packs[idx] = index_entry;
} else {
packs.push(index_entry);
}
// Write updated index back to file
let updated_content = serde_json::to_string_pretty(&index)?;
fs::write(index_file_path, updated_content)?;
match output_format {
OutputFormat::Table => {
output::print_success(&format!("✓ Index updated successfully: {}", index_path));
output::print_info(&format!(" Pack: {} v{}", pack_ref, version));
output::print_info(&format!(" Checksum: sha256:{}", checksum));
}
OutputFormat::Json => {
let response = serde_json::json!({
"success": true,
"index_file": index_path,
"pack_ref": pack_ref,
"version": version,
"checksum": format!("sha256:{}", checksum),
"action": if existing_index.is_some() { "updated" } else { "added" }
});
output::print_output(&response, OutputFormat::Json)?;
}
OutputFormat::Yaml => {
let response = serde_json::json!({
"success": true,
"index_file": index_path,
"pack_ref": pack_ref,
"version": version,
"checksum": format!("sha256:{}", checksum),
"action": if existing_index.is_some() { "updated" } else { "added" }
});
output::print_output(&response, OutputFormat::Yaml)?;
}
}
Ok(())
}
/// Merge multiple registry index files into one
pub async fn handle_index_merge(
output_path: String,
input_paths: Vec<String>,
force: bool,
output_format: OutputFormat,
) -> Result<()> {
// Check if output file exists
let output_file_path = Path::new(&output_path);
if output_file_path.exists() && !force {
return Err(anyhow::anyhow!(
"Output file already exists: {}. Use --force to overwrite.",
output_path
));
}
// Track all packs by ref (for deduplication)
let mut packs_map: HashMap<String, JsonValue> = HashMap::new();
let mut total_loaded = 0;
let mut duplicates_resolved = 0;
// Load and merge all input files
for input_path in &input_paths {
let input_file_path = Path::new(input_path);
if !input_file_path.exists() {
if output_format == OutputFormat::Table {
output::print_warning(&format!("Skipping missing file: {}", input_path));
}
continue;
}
if output_format == OutputFormat::Table {
output::print_info(&format!("Loading: {}", input_path));
}
let index_content = fs::read_to_string(input_file_path)?;
let index: JsonValue = serde_json::from_str(&index_content)?;
let packs = index
.get("packs")
.and_then(|p| p.as_array())
.ok_or_else(|| {
anyhow::anyhow!(
"Invalid index format in {}: missing 'packs' array",
input_path
)
})?;
for pack in packs {
let pack_ref = pack.get("ref").and_then(|r| r.as_str()).ok_or_else(|| {
anyhow::anyhow!("Pack entry missing 'ref' field in {}", input_path)
})?;
if packs_map.contains_key(pack_ref) {
// Check versions and keep the latest
let existing_version = packs_map[pack_ref]
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("0.0.0");
let new_version = pack
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("0.0.0");
// Simple string comparison (could use semver crate for proper comparison)
if new_version > existing_version {
if output_format == OutputFormat::Table {
output::print_info(&format!(
" Updating '{}' from {} to {}",
pack_ref, existing_version, new_version
));
}
packs_map.insert(pack_ref.to_string(), pack.clone());
} else {
if output_format == OutputFormat::Table {
output::print_info(&format!(
" Keeping '{}' at {} (newer than {})",
pack_ref, existing_version, new_version
));
}
}
duplicates_resolved += 1;
} else {
packs_map.insert(pack_ref.to_string(), pack.clone());
}
total_loaded += 1;
}
}
// Build merged index
let packs: Vec<JsonValue> = packs_map.into_values().collect();
let merged_index = serde_json::json!({
"version": "1.0",
"generated_at": chrono::Utc::now().to_rfc3339(),
"packs": packs
});
// Write merged index
let merged_content = serde_json::to_string_pretty(&merged_index)?;
fs::write(output_file_path, merged_content)?;
match output_format {
OutputFormat::Table => {
output::print_success(&format!(
"✓ Merged {} index files into {}",
input_paths.len(),
output_path
));
output::print_info(&format!(" Total packs loaded: {}", total_loaded));
output::print_info(&format!(" Unique packs: {}", packs.len()));
if duplicates_resolved > 0 {
output::print_info(&format!(" Duplicates resolved: {}", duplicates_resolved));
}
}
OutputFormat::Json => {
let response = serde_json::json!({
"success": true,
"output_file": output_path,
"sources_count": input_paths.len(),
"total_loaded": total_loaded,
"unique_packs": packs.len(),
"duplicates_resolved": duplicates_resolved
});
output::print_output(&response, OutputFormat::Json)?;
}
OutputFormat::Yaml => {
let response = serde_json::json!({
"success": true,
"output_file": output_path,
"sources_count": input_paths.len(),
"total_loaded": total_loaded,
"unique_packs": packs.len(),
"duplicates_resolved": duplicates_resolved
});
output::print_output(&response, OutputFormat::Yaml)?;
}
}
Ok(())
}

View File

@@ -0,0 +1,567 @@
use anyhow::Result;
use clap::Subcommand;
use serde::{Deserialize, Serialize};
use crate::client::ApiClient;
use crate::config::CliConfig;
use crate::output::{self, OutputFormat};
#[derive(Subcommand)]
pub enum RuleCommands {
/// List all rules
List {
/// Filter by pack name
#[arg(long)]
pack: Option<String>,
/// Filter by enabled status
#[arg(short, long)]
enabled: Option<bool>,
},
/// Show details of a specific rule
Show {
/// Rule reference (pack.rule or ID)
rule_ref: String,
},
/// Update a rule
Update {
/// Rule reference (pack.rule or ID)
rule_ref: String,
/// Update label
#[arg(long)]
label: Option<String>,
/// Update description
#[arg(long)]
description: Option<String>,
/// Update conditions as JSON string
#[arg(long)]
conditions: Option<String>,
/// Update action parameters as JSON string
#[arg(long)]
action_params: Option<String>,
/// Update trigger parameters as JSON string
#[arg(long)]
trigger_params: Option<String>,
/// Update enabled status
#[arg(long)]
enabled: Option<bool>,
},
/// Enable a rule
Enable {
/// Rule reference (pack.rule or ID)
rule_ref: String,
},
/// Disable a rule
Disable {
/// Rule reference (pack.rule or ID)
rule_ref: String,
},
/// Create a new rule
Create {
/// Rule name
#[arg(short, long)]
name: String,
/// Pack ID or name
#[arg(short, long)]
pack: String,
/// Trigger reference
#[arg(short, long)]
trigger: String,
/// Action reference
#[arg(short, long)]
action: String,
/// Rule description
#[arg(short, long)]
description: Option<String>,
/// Rule criteria as JSON string
#[arg(long)]
criteria: Option<String>,
/// Enable the rule immediately
#[arg(long)]
enabled: bool,
},
/// Delete a rule
Delete {
/// Rule reference (pack.rule or ID)
rule_ref: String,
/// Skip confirmation prompt
#[arg(short = 'y', long)]
yes: bool,
},
}
#[derive(Debug, Serialize, Deserialize)]
struct Rule {
id: i64,
#[serde(rename = "ref")]
rule_ref: String,
#[serde(default)]
pack: Option<i64>,
pack_ref: String,
label: String,
description: String,
#[serde(default)]
trigger: Option<i64>,
trigger_ref: String,
#[serde(default)]
action: Option<i64>,
action_ref: String,
enabled: bool,
created: String,
updated: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct RuleDetail {
id: i64,
#[serde(rename = "ref")]
rule_ref: String,
#[serde(default)]
pack: Option<i64>,
pack_ref: String,
label: String,
description: String,
#[serde(default)]
trigger: Option<i64>,
trigger_ref: String,
#[serde(default)]
action: Option<i64>,
action_ref: String,
enabled: bool,
#[serde(default)]
conditions: Option<serde_json::Value>,
#[serde(default)]
action_params: Option<serde_json::Value>,
#[serde(default)]
trigger_params: Option<serde_json::Value>,
created: String,
updated: String,
}
#[derive(Debug, Serialize)]
struct CreateRuleRequest {
name: String,
pack_id: String,
trigger_id: String,
action_id: String,
description: Option<String>,
criteria: Option<serde_json::Value>,
enabled: bool,
}
#[derive(Debug, Serialize)]
struct UpdateRuleRequest {
enabled: bool,
}
pub async fn handle_rule_command(
profile: &Option<String>,
command: RuleCommands,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
match command {
RuleCommands::List { pack, enabled } => {
handle_list(profile, pack, enabled, api_url, output_format).await
}
RuleCommands::Show { rule_ref } => {
handle_show(profile, rule_ref, api_url, output_format).await
}
RuleCommands::Update {
rule_ref,
label,
description,
conditions,
action_params,
trigger_params,
enabled,
} => {
handle_update(
profile,
rule_ref,
label,
description,
conditions,
action_params,
trigger_params,
enabled,
api_url,
output_format,
)
.await
}
RuleCommands::Enable { rule_ref } => {
handle_toggle(profile, rule_ref, true, api_url, output_format).await
}
RuleCommands::Disable { rule_ref } => {
handle_toggle(profile, rule_ref, false, api_url, output_format).await
}
RuleCommands::Create {
name,
pack,
trigger,
action,
description,
criteria,
enabled,
} => {
handle_create(
profile,
name,
pack,
trigger,
action,
description,
criteria,
enabled,
api_url,
output_format,
)
.await
}
RuleCommands::Delete { rule_ref, yes } => {
handle_delete(profile, rule_ref, yes, api_url, output_format).await
}
}
}
async fn handle_list(
profile: &Option<String>,
pack: Option<String>,
enabled: Option<bool>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let mut query_params = Vec::new();
if let Some(pack_name) = pack {
query_params.push(format!("pack={}", pack_name));
}
if let Some(is_enabled) = enabled {
query_params.push(format!("enabled={}", is_enabled));
}
let path = if query_params.is_empty() {
"/rules".to_string()
} else {
format!("/rules?{}", query_params.join("&"))
};
let rules: Vec<Rule> = client.get(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&rules, output_format)?;
}
OutputFormat::Table => {
if rules.is_empty() {
output::print_info("No rules found");
} else {
let mut table = output::create_table();
output::add_header(
&mut table,
vec!["ID", "Pack", "Name", "Trigger", "Action", "Enabled"],
);
for rule in rules {
table.add_row(vec![
rule.id.to_string(),
rule.pack_ref.clone(),
rule.label.clone(),
rule.trigger_ref.clone(),
rule.action_ref.clone(),
output::format_bool(rule.enabled),
]);
}
println!("{}", table);
}
}
}
Ok(())
}
async fn handle_show(
profile: &Option<String>,
rule_ref: String,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let path = format!("/rules/{}", rule_ref);
let rule: RuleDetail = client.get(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&rule, output_format)?;
}
OutputFormat::Table => {
output::print_section(&format!("Rule: {}", rule.rule_ref));
output::print_key_value_table(vec![
("ID", rule.id.to_string()),
("Ref", rule.rule_ref.clone()),
("Pack", rule.pack_ref.clone()),
("Label", rule.label.clone()),
("Description", rule.description.clone()),
("Trigger", rule.trigger_ref.clone()),
("Action", rule.action_ref.clone()),
("Enabled", output::format_bool(rule.enabled)),
("Created", output::format_timestamp(&rule.created)),
("Updated", output::format_timestamp(&rule.updated)),
]);
if let Some(conditions) = rule.conditions {
if !conditions.is_null() {
output::print_section("Conditions");
println!("{}", serde_json::to_string_pretty(&conditions)?);
}
}
if let Some(action_params) = rule.action_params {
if !action_params.is_null() {
output::print_section("Action Parameters");
println!("{}", serde_json::to_string_pretty(&action_params)?);
}
}
if let Some(trigger_params) = rule.trigger_params {
if !trigger_params.is_null() {
output::print_section("Trigger Parameters");
println!("{}", serde_json::to_string_pretty(&trigger_params)?);
}
}
}
}
Ok(())
}
async fn handle_update(
profile: &Option<String>,
rule_ref: String,
label: Option<String>,
description: Option<String>,
conditions: Option<String>,
action_params: Option<String>,
trigger_params: Option<String>,
enabled: Option<bool>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
// Check that at least one field is provided
if label.is_none()
&& description.is_none()
&& conditions.is_none()
&& action_params.is_none()
&& trigger_params.is_none()
&& enabled.is_none()
{
anyhow::bail!("At least one field must be provided to update");
}
// Parse JSON fields
let conditions_json = if let Some(cond) = conditions {
Some(serde_json::from_str(&cond)?)
} else {
None
};
let action_params_json = if let Some(params) = action_params {
Some(serde_json::from_str(&params)?)
} else {
None
};
let trigger_params_json = if let Some(params) = trigger_params {
Some(serde_json::from_str(&params)?)
} else {
None
};
#[derive(Serialize)]
struct UpdateRuleRequestCli {
#[serde(skip_serializing_if = "Option::is_none")]
label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
conditions: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
action_params: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
trigger_params: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
enabled: Option<bool>,
}
let request = UpdateRuleRequestCli {
label,
description,
conditions: conditions_json,
action_params: action_params_json,
trigger_params: trigger_params_json,
enabled,
};
let path = format!("/rules/{}", rule_ref);
let rule: RuleDetail = client.put(&path, &request).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&rule, output_format)?;
}
OutputFormat::Table => {
output::print_success(&format!("Rule '{}' updated successfully", rule.rule_ref));
output::print_key_value_table(vec![
("ID", rule.id.to_string()),
("Ref", rule.rule_ref.clone()),
("Pack", rule.pack_ref.clone()),
("Label", rule.label.clone()),
("Description", rule.description.clone()),
("Trigger", rule.trigger_ref.clone()),
("Action", rule.action_ref.clone()),
("Enabled", output::format_bool(rule.enabled)),
("Updated", output::format_timestamp(&rule.updated)),
]);
}
}
Ok(())
}
async fn handle_toggle(
profile: &Option<String>,
rule_ref: String,
enabled: bool,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let request = UpdateRuleRequest { enabled };
let path = format!("/rules/{}", rule_ref);
let rule: Rule = client.patch(&path, &request).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&rule, output_format)?;
}
OutputFormat::Table => {
let action = if enabled { "enabled" } else { "disabled" };
output::print_success(&format!("Rule '{}' {}", rule.rule_ref, action));
}
}
Ok(())
}
async fn handle_create(
profile: &Option<String>,
name: String,
pack: String,
trigger: String,
action: String,
description: Option<String>,
criteria: Option<String>,
enabled: bool,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let criteria_value = if let Some(criteria_str) = criteria {
Some(serde_json::from_str(&criteria_str)?)
} else {
None
};
let request = CreateRuleRequest {
name: name.clone(),
pack_id: pack,
trigger_id: trigger,
action_id: action,
description,
criteria: criteria_value,
enabled,
};
let rule: Rule = client.post("/rules", &request).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&rule, output_format)?;
}
OutputFormat::Table => {
output::print_success(&format!("Rule '{}' created successfully", rule.rule_ref));
output::print_info(&format!("ID: {}", rule.id));
output::print_info(&format!("Enabled: {}", rule.enabled));
}
}
Ok(())
}
async fn handle_delete(
profile: &Option<String>,
rule_ref: String,
yes: bool,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
// Confirm deletion unless --yes is provided
if !yes && matches!(output_format, OutputFormat::Table) {
let confirm = dialoguer::Confirm::new()
.with_prompt(format!(
"Are you sure you want to delete rule '{}'?",
rule_ref
))
.default(false)
.interact()?;
if !confirm {
output::print_info("Deletion cancelled");
return Ok(());
}
}
let path = format!("/rules/{}", rule_ref);
client.delete_no_response(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
let msg = serde_json::json!({"message": "Rule deleted successfully"});
output::print_output(&msg, output_format)?;
}
OutputFormat::Table => {
output::print_success(&format!("Rule '{}' deleted successfully", rule_ref));
}
}
Ok(())
}

View File

@@ -0,0 +1,187 @@
use anyhow::Result;
use clap::Subcommand;
use serde::{Deserialize, Serialize};
use crate::client::ApiClient;
use crate::config::CliConfig;
use crate::output::{self, OutputFormat};
#[derive(Subcommand)]
pub enum SensorCommands {
/// List all sensors
List {
/// Filter by pack name
#[arg(long)]
pack: Option<String>,
},
/// Show details of a specific sensor
Show {
/// Sensor reference (pack.sensor or ID)
sensor_ref: String,
},
}
#[derive(Debug, Serialize, Deserialize)]
struct Sensor {
id: i64,
#[serde(rename = "ref")]
sensor_ref: String,
#[serde(default)]
pack: Option<i64>,
#[serde(default)]
pack_ref: Option<String>,
label: String,
description: Option<String>,
#[serde(default)]
trigger_types: Vec<String>,
enabled: bool,
created: String,
updated: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct SensorDetail {
id: i64,
#[serde(rename = "ref")]
sensor_ref: String,
#[serde(default)]
pack: Option<i64>,
#[serde(default)]
pack_ref: Option<String>,
label: String,
description: Option<String>,
#[serde(default)]
trigger_types: Vec<String>,
#[serde(default)]
entry_point: Option<String>,
enabled: bool,
#[serde(default)]
poll_interval: Option<i32>,
#[serde(default)]
metadata: Option<serde_json::Value>,
created: String,
updated: String,
}
pub async fn handle_sensor_command(
profile: &Option<String>,
command: SensorCommands,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
match command {
SensorCommands::List { pack } => handle_list(pack, profile, api_url, output_format).await,
SensorCommands::Show { sensor_ref } => {
handle_show(sensor_ref, profile, api_url, output_format).await
}
}
}
async fn handle_list(
pack: Option<String>,
profile: &Option<String>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let path = if let Some(pack_name) = pack {
format!("/sensors?pack={}", pack_name)
} else {
"/sensors".to_string()
};
let sensors: Vec<Sensor> = client.get(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&sensors, output_format)?;
}
OutputFormat::Table => {
if sensors.is_empty() {
output::print_info("No sensors found");
} else {
let mut table = output::create_table();
output::add_header(
&mut table,
vec!["ID", "Pack", "Name", "Trigger", "Enabled", "Description"],
);
for sensor in sensors {
table.add_row(vec![
sensor.id.to_string(),
sensor.pack_ref.as_deref().unwrap_or("").to_string(),
sensor.label.clone(),
sensor.trigger_types.join(", "),
output::format_bool(sensor.enabled),
output::truncate(&sensor.description.unwrap_or_default(), 50),
]);
}
println!("{}", table);
}
}
}
Ok(())
}
async fn handle_show(
sensor_ref: String,
profile: &Option<String>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let path = format!("/sensors/{}", sensor_ref);
let sensor: SensorDetail = client.get(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&sensor, output_format)?;
}
OutputFormat::Table => {
output::print_section(&format!("Sensor: {}", sensor.sensor_ref));
output::print_key_value_table(vec![
("ID", sensor.id.to_string()),
("Ref", sensor.sensor_ref.clone()),
(
"Pack",
sensor.pack_ref.as_deref().unwrap_or("None").to_string(),
),
("Label", sensor.label.clone()),
(
"Description",
sensor.description.unwrap_or_else(|| "None".to_string()),
),
("Trigger Types", sensor.trigger_types.join(", ")),
(
"Entry Point",
sensor.entry_point.as_deref().unwrap_or("N/A").to_string(),
),
("Enabled", output::format_bool(sensor.enabled)),
(
"Poll Interval",
sensor
.poll_interval
.map(|i| format!("{}s", i))
.unwrap_or_else(|| "N/A".to_string()),
),
("Created", output::format_timestamp(&sensor.created)),
("Updated", output::format_timestamp(&sensor.updated)),
]);
if let Some(metadata) = sensor.metadata {
if !metadata.is_null() {
output::print_section("Metadata");
println!("{}", serde_json::to_string_pretty(&metadata)?);
}
}
}
}
Ok(())
}

View File

@@ -0,0 +1,346 @@
use anyhow::Result;
use clap::Subcommand;
use serde::{Deserialize, Serialize};
use crate::client::ApiClient;
use crate::config::CliConfig;
use crate::output::{self, OutputFormat};
#[derive(Subcommand)]
pub enum TriggerCommands {
/// List all triggers
List {
/// Filter by pack name
#[arg(long)]
pack: Option<String>,
},
/// Show details of a specific trigger
Show {
/// Trigger reference (pack.trigger or ID)
trigger_ref: String,
},
/// Update a trigger
Update {
/// Trigger reference (pack.trigger or ID)
trigger_ref: String,
/// Update label
#[arg(long)]
label: Option<String>,
/// Update description
#[arg(long)]
description: Option<String>,
/// Update enabled status
#[arg(long)]
enabled: Option<bool>,
},
/// Delete a trigger
Delete {
/// Trigger reference (pack.trigger or ID)
trigger_ref: String,
/// Skip confirmation prompt
#[arg(short, long)]
yes: bool,
},
}
#[derive(Debug, Serialize, Deserialize)]
struct Trigger {
id: i64,
#[serde(rename = "ref")]
trigger_ref: String,
#[serde(default)]
pack: Option<i64>,
#[serde(default)]
pack_ref: Option<String>,
label: String,
description: Option<String>,
enabled: bool,
#[serde(default)]
param_schema: Option<serde_json::Value>,
#[serde(default)]
out_schema: Option<serde_json::Value>,
#[serde(default)]
webhook_enabled: Option<bool>,
#[serde(default)]
webhook_key: Option<String>,
created: String,
updated: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct TriggerDetail {
id: i64,
#[serde(rename = "ref")]
trigger_ref: String,
#[serde(default)]
pack: Option<i64>,
#[serde(default)]
pack_ref: Option<String>,
label: String,
description: Option<String>,
enabled: bool,
#[serde(default)]
param_schema: Option<serde_json::Value>,
#[serde(default)]
out_schema: Option<serde_json::Value>,
#[serde(default)]
webhook_enabled: Option<bool>,
#[serde(default)]
webhook_key: Option<String>,
created: String,
updated: String,
}
pub async fn handle_trigger_command(
profile: &Option<String>,
command: TriggerCommands,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
match command {
TriggerCommands::List { pack } => handle_list(pack, profile, api_url, output_format).await,
TriggerCommands::Show { trigger_ref } => {
handle_show(trigger_ref, profile, api_url, output_format).await
}
TriggerCommands::Update {
trigger_ref,
label,
description,
enabled,
} => {
handle_update(
trigger_ref,
label,
description,
enabled,
profile,
api_url,
output_format,
)
.await
}
TriggerCommands::Delete { trigger_ref, yes } => {
handle_delete(trigger_ref, yes, profile, api_url, output_format).await
}
}
}
async fn handle_list(
pack: Option<String>,
profile: &Option<String>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let path = if let Some(pack_name) = pack {
format!("/triggers?pack={}", pack_name)
} else {
"/triggers".to_string()
};
let triggers: Vec<Trigger> = client.get(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&triggers, output_format)?;
}
OutputFormat::Table => {
if triggers.is_empty() {
output::print_info("No triggers found");
} else {
let mut table = output::create_table();
output::add_header(&mut table, vec!["ID", "Pack", "Name", "Description"]);
for trigger in triggers {
table.add_row(vec![
trigger.id.to_string(),
trigger.pack_ref.as_deref().unwrap_or("").to_string(),
trigger.label.clone(),
output::truncate(&trigger.description.unwrap_or_default(), 50),
]);
}
println!("{}", table);
}
}
}
Ok(())
}
async fn handle_show(
trigger_ref: String,
profile: &Option<String>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let path = format!("/triggers/{}", trigger_ref);
let trigger: TriggerDetail = client.get(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&trigger, output_format)?;
}
OutputFormat::Table => {
output::print_section(&format!("Trigger: {}", trigger.trigger_ref));
output::print_key_value_table(vec![
("ID", trigger.id.to_string()),
("Ref", trigger.trigger_ref.clone()),
(
"Pack",
trigger.pack_ref.as_deref().unwrap_or("None").to_string(),
),
("Label", trigger.label.clone()),
(
"Description",
trigger.description.unwrap_or_else(|| "None".to_string()),
),
("Enabled", output::format_bool(trigger.enabled)),
(
"Webhook Enabled",
output::format_bool(trigger.webhook_enabled.unwrap_or(false)),
),
("Created", output::format_timestamp(&trigger.created)),
("Updated", output::format_timestamp(&trigger.updated)),
]);
if let Some(webhook_key) = &trigger.webhook_key {
output::print_section("Webhook");
output::print_info(&format!("Key: {}", webhook_key));
}
if let Some(param_schema) = &trigger.param_schema {
if !param_schema.is_null() {
output::print_section("Parameter Schema");
println!("{}", serde_json::to_string_pretty(param_schema)?);
}
}
if let Some(out_schema) = &trigger.out_schema {
if !out_schema.is_null() {
output::print_section("Output Schema");
println!("{}", serde_json::to_string_pretty(out_schema)?);
}
}
}
}
Ok(())
}
async fn handle_update(
trigger_ref: String,
label: Option<String>,
description: Option<String>,
enabled: Option<bool>,
profile: &Option<String>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
// Check that at least one field is provided
if label.is_none() && description.is_none() && enabled.is_none() {
anyhow::bail!("At least one field must be provided to update");
}
#[derive(Serialize)]
struct UpdateTriggerRequest {
#[serde(skip_serializing_if = "Option::is_none")]
label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
enabled: Option<bool>,
}
let request = UpdateTriggerRequest {
label,
description,
enabled,
};
let path = format!("/triggers/{}", trigger_ref);
let trigger: TriggerDetail = client.put(&path, &request).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&trigger, output_format)?;
}
OutputFormat::Table => {
output::print_success(&format!(
"Trigger '{}' updated successfully",
trigger.trigger_ref
));
output::print_key_value_table(vec![
("ID", trigger.id.to_string()),
("Ref", trigger.trigger_ref.clone()),
(
"Pack",
trigger.pack_ref.as_deref().unwrap_or("None").to_string(),
),
("Label", trigger.label.clone()),
(
"Description",
trigger.description.unwrap_or_else(|| "None".to_string()),
),
("Enabled", output::format_bool(trigger.enabled)),
("Updated", output::format_timestamp(&trigger.updated)),
]);
}
}
Ok(())
}
async fn handle_delete(
trigger_ref: String,
yes: bool,
profile: &Option<String>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
// Confirm deletion unless --yes is provided
if !yes && matches!(output_format, OutputFormat::Table) {
let confirm = dialoguer::Confirm::new()
.with_prompt(format!(
"Are you sure you want to delete trigger '{}'?",
trigger_ref
))
.default(false)
.interact()?;
if !confirm {
output::print_info("Delete cancelled");
return Ok(());
}
}
let path = format!("/triggers/{}", trigger_ref);
client.delete_no_response(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
let msg = serde_json::json!({"message": "Trigger deleted successfully"});
output::print_output(&msg, output_format)?;
}
OutputFormat::Table => {
output::print_success(&format!("Trigger '{}' deleted successfully", trigger_ref));
}
}
Ok(())
}