log streams in watched cli executions

This commit is contained in:
2026-04-07 08:55:27 -05:00
parent f93e9229d2
commit ed74dfad6c
15 changed files with 1368 additions and 153 deletions

View File

@@ -59,6 +59,7 @@ sha2 = { workspace = true }
colored = "3.1"
comfy-table = { version = "7.2", features = ["custom_styling"] }
dialoguer = "0.12"
terminal_size = "0.4"
# Authentication
jsonwebtoken = { workspace = true }

View File

@@ -175,11 +175,11 @@ attune action execute core.echo --param message="Hello World" --param count=3
# With JSON parameters
attune action execute core.echo --params-json '{"message": "Hello", "count": 5}'
# Wait for completion
attune action execute core.long_task --wait
# Watch until completion
attune action execute core.long_task --watch
# Wait with custom timeout (default 300 seconds)
attune action execute core.long_task --wait --timeout 600
# Watch with custom timeout (default 300 seconds)
attune action execute core.long_task --watch --timeout 600
```
## Rule Management
@@ -588,4 +588,4 @@ Key dependencies:
- `colored`: Terminal colors
- `comfy-table`: Table formatting
- `dialoguer`: Interactive prompts
- `indicatif`: Progress indicators (for future use)
- `indicatif`: Progress indicators (for future use)

View File

@@ -68,17 +68,17 @@ pub enum ActionCommands {
#[arg(long, conflicts_with = "param")]
params_json: Option<String>,
/// Wait for execution to complete
/// Watch execution until it completes
#[arg(short, long)]
wait: bool,
watch: bool,
/// Timeout in seconds when waiting (default: 300)
#[arg(long, default_value = "300", requires = "wait")]
/// Timeout in seconds when watching (default: 300)
#[arg(long, default_value = "300", requires = "watch")]
timeout: u64,
/// Notifier WebSocket base URL (e.g. ws://localhost:8081).
/// Derived from --api-url automatically when not set.
#[arg(long, requires = "wait")]
#[arg(long, requires = "watch")]
notifier_url: Option<String>,
},
}
@@ -186,7 +186,7 @@ pub async fn handle_action_command(
action_ref,
param,
params_json,
wait,
watch,
timeout,
notifier_url,
} => {
@@ -196,7 +196,7 @@ pub async fn handle_action_command(
params_json,
profile,
api_url,
wait,
watch,
timeout,
notifier_url,
output_format,
@@ -307,7 +307,7 @@ async fn handle_show(
if let Some(params) = action.param_schema {
if !params.is_null() {
output::print_section("Parameters Schema");
println!("{}", serde_json::to_string_pretty(&params)?);
output::print_schema(&params)?;
}
}
}
@@ -428,7 +428,7 @@ async fn handle_execute(
params_json: Option<String>,
profile: &Option<String>,
api_url: &Option<String>,
wait: bool,
watch: bool,
timeout: u64,
notifier_url: Option<String>,
output_format: OutputFormat,
@@ -468,7 +468,7 @@ async fn handle_execute(
let path = "/executions/execute".to_string();
let execution: Execution = client.post(&path, &request).await?;
if !wait {
if !watch {
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&execution, output_format)?;
@@ -492,22 +492,22 @@ async fn handle_execute(
));
}
let verbose = matches!(output_format, OutputFormat::Table);
let watch_task = if verbose {
Some(spawn_execution_output_watch(
ApiClient::from_config(&config, api_url),
execution.id,
verbose,
))
} else {
None
};
let interactive_wait = true;
let stream_live_logs = true;
let debug_wait = false;
let watch_task = Some(spawn_execution_output_watch(
ApiClient::from_config(&config, api_url),
execution.id,
interactive_wait,
stream_live_logs,
debug_wait,
));
let summary = wait_for_execution(WaitOptions {
execution_id: execution.id,
timeout_secs: timeout,
api_client: &mut client,
notifier_ws_url: notifier_url,
verbose,
verbose: debug_wait,
})
.await?;
let suppress_final_stdout = watch_task

View File

@@ -124,17 +124,17 @@ enum Commands {
#[arg(long, conflicts_with = "param")]
params_json: Option<String>,
/// Wait for execution to complete
/// Watch execution until it completes
#[arg(short, long)]
wait: bool,
watch: bool,
/// Timeout in seconds when waiting (default: 300)
#[arg(long, default_value = "300", requires = "wait")]
/// Timeout in seconds when watching (default: 300)
#[arg(long, default_value = "300", requires = "watch")]
timeout: u64,
/// Notifier WebSocket base URL (e.g. ws://localhost:8081).
/// Derived from --api-url automatically when not set.
#[arg(long, requires = "wait")]
#[arg(long, requires = "watch")]
notifier_url: Option<String>,
},
}
@@ -243,7 +243,7 @@ async fn main() {
action_ref,
param,
params_json,
wait,
watch,
timeout,
notifier_url,
} => {
@@ -254,7 +254,7 @@ async fn main() {
action_ref,
param,
params_json,
wait,
watch,
timeout,
notifier_url,
},

View File

@@ -4,6 +4,7 @@ use colored::Colorize;
use comfy_table::{modifiers::UTF8_ROUND_CORNERS, presets::UTF8_FULL, Cell, Color, Table};
use serde::Serialize;
use std::fmt::Display;
use terminal_size::{terminal_size, Width};
/// Output format for CLI commands
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq)]
@@ -88,14 +89,69 @@ pub fn add_header(table: &mut Table, headers: Vec<&str>) {
pub fn print_key_value_table(pairs: Vec<(&str, String)>) {
let mut table = create_table();
add_header(&mut table, vec!["Key", "Value"]);
let width = terminal_width();
let key_width = pairs
.iter()
.map(|(key, _)| display_width(key))
.max()
.unwrap_or(3)
.clamp(8, 18);
let value_width = width.saturating_sub(key_width + 9).max(20);
for (key, value) in pairs {
table.add_row(vec![Cell::new(key).fg(Color::Yellow), Cell::new(value)]);
table.add_row(vec![
Cell::new(wrap_text(key, key_width)).fg(Color::Yellow),
Cell::new(wrap_text(&value, value_width)),
]);
}
println!("{}", table);
}
/// Print a schema in a readable multi-line format instead of a raw JSON dump.
pub fn print_schema(schema: &serde_json::Value) -> Result<()> {
if let Some(properties) = schema.as_object() {
if properties.values().all(|value| value.is_object()) {
let width = terminal_width();
let content_width = width.saturating_sub(4).max(24);
let mut names = properties.keys().collect::<Vec<_>>();
names.sort();
for (index, name) in names.into_iter().enumerate() {
if index > 0 {
println!();
}
println!("{}", name.bold());
if let Some(definition) = properties.get(name).and_then(|value| value.as_object()) {
print_schema_field("Type", &schema_type_label(definition), content_width);
if let Some(default) = definition.get("default") {
print_schema_field("Default", &compact_json(default), content_width);
}
if let Some(description) = definition
.get("description")
.and_then(|value| value.as_str())
{
print_schema_field("Description", description, content_width);
}
let constraints = schema_constraints(definition);
if !constraints.is_empty() {
print_schema_field("Constraints", &constraints.join(", "), content_width);
}
}
}
return Ok(());
}
}
println!("{}", serde_yaml_ng::to_string(schema)?);
Ok(())
}
/// Print a simple list
pub fn print_list(items: Vec<String>) {
for item in items {
@@ -137,6 +193,146 @@ pub fn truncate(s: &str, max_len: usize) -> String {
}
}
fn terminal_width() -> usize {
terminal_size()
.map(|(Width(width), _)| width as usize)
.filter(|width| *width > 20)
.unwrap_or(100)
}
fn display_width(value: &str) -> usize {
value.chars().count()
}
fn wrap_text(value: &str, width: usize) -> String {
let width = width.max(1);
let mut wrapped = Vec::new();
for paragraph in value.split('\n') {
if paragraph.is_empty() {
wrapped.push(String::new());
continue;
}
let mut line = String::new();
for word in paragraph.split_whitespace() {
if line.is_empty() {
append_wrapped_word(&mut wrapped, &mut line, word, width);
continue;
}
if display_width(&line) + 1 + display_width(word) <= width {
line.push(' ');
line.push_str(word);
} else {
wrapped.push(line);
line = String::new();
append_wrapped_word(&mut wrapped, &mut line, word, width);
}
}
if !line.is_empty() {
wrapped.push(line);
}
}
wrapped.join("\n")
}
fn append_wrapped_word(
lines: &mut Vec<String>,
current_line: &mut String,
word: &str,
width: usize,
) {
if display_width(word) <= width {
current_line.push_str(word);
return;
}
let mut chunk = String::new();
for ch in word.chars() {
chunk.push(ch);
if display_width(&chunk) >= width {
if current_line.is_empty() {
lines.push(std::mem::take(&mut chunk));
} else {
lines.push(std::mem::take(current_line));
lines.push(std::mem::take(&mut chunk));
}
}
}
if !chunk.is_empty() {
current_line.push_str(&chunk);
}
}
fn schema_type_label(definition: &serde_json::Map<String, serde_json::Value>) -> String {
match definition.get("type") {
Some(serde_json::Value::String(kind)) => kind.clone(),
Some(serde_json::Value::Array(kinds)) => kinds
.iter()
.filter_map(|value| value.as_str())
.collect::<Vec<_>>()
.join(" | "),
_ => "any".to_string(),
}
}
fn schema_constraints(definition: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
let mut constraints = Vec::new();
if let Some(values) = definition.get("enum").and_then(|value| value.as_array()) {
constraints.push(format!(
"enum: {}",
values
.iter()
.map(compact_json)
.collect::<Vec<_>>()
.join(", ")
));
}
for key in [
"minimum",
"maximum",
"minLength",
"maxLength",
"pattern",
"format",
] {
if let Some(value) = definition.get(key) {
constraints.push(format!("{key}: {}", compact_json(value)));
}
}
constraints
}
fn compact_json(value: &serde_json::Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
}
fn print_schema_field(label: &str, value: &str, width: usize) {
let indent = " ";
let label_prefix = format!("{indent}{label}: ");
let continuation = " ".repeat(label_prefix.chars().count());
let wrapped = wrap_text(
value,
width.saturating_sub(label_prefix.chars().count()).max(12),
);
let mut lines = wrapped.lines();
if let Some(first_line) = lines.next() {
println!("{label_prefix}{first_line}");
}
for line in lines {
println!("{continuation}{line}");
}
}
/// Format a timestamp in a human-readable way
pub fn format_timestamp(timestamp: &str) -> String {
// Try to parse and format nicely, otherwise return as-is

File diff suppressed because it is too large Load Diff

View File

@@ -109,7 +109,7 @@ cargo test --package attune-cli --tests -- --test-threads=1
- ✅ Execute with multiple parameters
- ✅ Execute with JSON parameters
- ✅ Execute without parameters
- ✅ Execute with --wait flag
- ✅ Execute with --watch flag
- ✅ Execute with --async flag
- ✅ List actions by pack
- ✅ Invalid parameter formats
@@ -287,4 +287,4 @@ For more information:
- [CLI Usage Guide](../README.md)
- [CLI Profile Management](../../../docs/cli-profiles.md)
- [API Documentation](../../../docs/api-*.md)
- [Main Project README](../../../README.md)
- [Main Project README](../../../README.md)

View File

@@ -324,7 +324,7 @@ async fn test_action_execute_wait_for_completion() {
.arg("core.echo")
.arg("--param")
.arg("message=test")
.arg("--wait");
.arg("--watch");
cmd.assert()
.success()
@@ -476,7 +476,7 @@ async fn test_action_execute_async_flag() {
.arg("action")
.arg("execute")
.arg("core.long_running");
// Note: default behavior is async (no --wait), so no --async flag needed
// Note: default behavior is async (no --watch), so no --async flag needed
cmd.assert()
.success()