Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 2 additions & 16 deletions src/commands/images.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,25 +135,11 @@ async fn images_list(args: ImagesListArgs) -> Result<()> {
let response = client.get(&path).await?;
let body = response.bytes().await?;

let result = utils::handle_paginated_response::<types::image::Summary>(&body, args.json)?;

if args.json {
utils::print_json_bytes(&body)?;
return Ok(());
}

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct ListResponse {
data: Vec<types::image::Summary>,
pagination: PaginationInfo,
}

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct PaginationInfo {
total_items: i64,
}

let result: ListResponse = serde_json::from_slice(&body).context("failed to parse response")?;
let headers = ["ID", "REPOSITORY:TAG", "SIZE", "IN USE"];
let rows = result
.data
Expand Down
215 changes: 215 additions & 0 deletions src/commands/interactive.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
use anyhow::Result;
use clap::{Args, Parser};
use dialoguer::{Input, Select, theme::ColorfulTheme};
use std::future::Future;
use std::io::IsTerminal;
use std::pin::Pin;

use crate::core::{output, utils};

use super::{Cli, run};

#[derive(Args, Debug)]
pub struct InteractiveCmd {}

impl InteractiveCmd {
pub async fn run(self) -> Result<()> {
interactive_main().await
}
}

async fn interactive_main() -> Result<()> {
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
anyhow::bail!("interactive mode requires a TTY");
}

loop {
let items = vec![
"Auth",
"Config",
"Environments",
"Resources (list)",
"System",
"Exit",
];

let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Arcane CLI — Interactive")
.items(&items)
.default(0)
.interact_opt()?;

match selection {
Some(0) => auth_menu().await?,
Some(1) => config_menu().await?,
Some(2) => environments_menu().await?,
Some(3) => resources_menu().await?,
Some(4) => system_menu().await?,
_ => break,
}
}

Ok(())
}

async fn auth_menu() -> Result<()> {
let items = vec!["Login", "Logout", "Who am I", "Back"];
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Auth")
.items(&items)
.default(0)
.interact_opt()?;

match selection {
Some(0) => run_command(vec!["auth".into(), "login".into()]).await?,
Some(1) => run_command(vec!["auth".into(), "logout".into()]).await?,
Some(2) => run_command(vec!["auth".into(), "me".into()]).await?,
_ => {}
}

Ok(())
}

async fn config_menu() -> Result<()> {
let items = vec![
"Open config wizard",
"Show config",
"Test connection",
"Show config path",
"Back",
];

let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Config")
.items(&items)
.default(0)
.interact_opt()?;

match selection {
Some(0) => run_command(vec!["config".into()]).await?,
Some(1) => run_command(vec!["config".into(), "show".into()]).await?,
Some(2) => run_command(vec!["config".into(), "test".into()]).await?,
Some(3) => run_command(vec!["config".into(), "path".into()]).await?,
_ => {}
}

Ok(())
}

async fn environments_menu() -> Result<()> {
let items = vec![
"List",
"Switch (interactive)",
"Get by ID",
"Test by ID",
"Back",
];

let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Environments")
.items(&items)
.default(0)
.interact_opt()?;

match selection {
Some(0) => run_command(vec!["environments".into(), "list".into()]).await?,
Some(1) => run_command(vec!["environments".into(), "switch".into()]).await?,
Some(2) => {
if let Some(id) = prompt_id("Environment ID")? {
run_command(vec!["environments".into(), "get".into(), id]).await?;
}
}
Some(3) => {
if let Some(id) = prompt_id("Environment ID")? {
run_command(vec!["environments".into(), "test".into(), id]).await?;
}
}
_ => {}
}

Ok(())
}

async fn resources_menu() -> Result<()> {
let items = vec![
"Projects",
"Containers",
"Images",
"Networks",
"Volumes",
"Environments",
"Back",
];

let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Resources — List")
.items(&items)
.default(0)
.interact_opt()?;

match selection {
Some(0) => run_command(vec!["projects".into(), "list".into()]).await?,
Some(1) => run_command(vec!["containers".into(), "list".into()]).await?,
Some(2) => run_command(vec!["images".into(), "list".into()]).await?,
Some(3) => run_command(vec!["networks".into(), "list".into()]).await?,
Some(4) => run_command(vec!["volumes".into(), "list".into()]).await?,
Some(5) => run_command(vec!["environments".into(), "list".into()]).await?,
_ => {}
}

Ok(())
}

async fn system_menu() -> Result<()> {
let items = vec!["Docker info", "Upgrade check", "Prune system", "Back"];

let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("System")
.items(&items)
.default(0)
.interact_opt()?;

match selection {
Some(0) => run_command(vec!["system".into(), "docker-info".into()]).await?,
Some(1) => run_command(vec!["system".into(), "upgrade-check".into()]).await?,
Some(2) => {
let confirmed = utils::confirm(
"Prune unused system resources? This may remove unused images and data. (y/N): ",
)?;
if confirmed {
run_command(vec!["system".into(), "prune".into()]).await?;
} else {
output::info("Cancelled");
}
}
_ => {}
}

Ok(())
}

fn prompt_id(prompt: &str) -> Result<Option<String>> {
let input: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt(prompt)
.allow_empty(true)
.interact_text()?;

let trimmed = input.trim();
if trimmed.is_empty() {
Ok(None)
} else {
Ok(Some(trimmed.to_string()))
}
}

fn run_command(args: Vec<String>) -> Pin<Box<dyn Future<Output = Result<()>>>> {
Box::pin(async move {
let mut full_args = Vec::with_capacity(args.len() + 1);
full_args.push("arcane-cli".to_string());
full_args.extend(args);

let cli = Cli::try_parse_from(full_args).map_err(|err| anyhow::anyhow!(err.to_string()))?;

run(cli).await
})
}
15 changes: 15 additions & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod generate;
pub mod gitops;
pub mod image_updates;
pub mod images;
pub mod interactive;
pub mod job_schedules;
pub mod networks;
pub mod notifications;
Expand Down Expand Up @@ -56,6 +57,17 @@ pub struct Cli {
help = "Enable detailed operation logging"
)]
pub verbose: bool,
#[arg(long = "no-color", global = true, help = "Disable colored output")]
pub no_color: bool,
#[arg(long = "no-spinner", global = true, help = "Disable progress spinners")]
pub no_spinner: bool,
#[arg(
short = 'y',
long = "yes",
global = true,
help = "Automatically answer yes to prompts"
)]
pub yes: bool,
#[command(subcommand)]
pub command: Option<Command>,
}
Expand Down Expand Up @@ -87,6 +99,8 @@ pub enum Command {
ImageUpdates(image_updates::ImageUpdatesCmd),
#[command(visible_aliases = ["image", "i"])]
Images(images::ImagesCmd),
#[command(visible_aliases = ["menu", "ui"])]
Interactive(interactive::InteractiveCmd),
#[command(name = "job-schedules", visible_aliases = ["jobs", "job-schedule", "schedules"])]
JobSchedules(job_schedules::JobSchedulesCmd),
#[command(visible_aliases = ["network", "net", "n"])]
Expand Down Expand Up @@ -126,6 +140,7 @@ pub async fn run(cli: Cli) -> Result<()> {
Command::GitOps(cmd) => cmd.run().await,
Command::ImageUpdates(cmd) => cmd.run().await,
Command::Images(cmd) => cmd.run().await,
Command::Interactive(cmd) => cmd.run().await,
Command::JobSchedules(cmd) => cmd.run().await,
Command::Networks(cmd) => cmd.run().await,
Command::Notifications(cmd) => cmd.run().await,
Expand Down
37 changes: 35 additions & 2 deletions src/core/context.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::io::IsTerminal;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;

Expand All @@ -6,11 +7,28 @@ use indicatif::{ProgressBar, ProgressStyle};
/// Global output context controlling verbosity levels
static QUIET_MODE: AtomicBool = AtomicBool::new(false);
static VERBOSE_MODE: AtomicBool = AtomicBool::new(false);
static COLOR_MODE: AtomicBool = AtomicBool::new(true);
static SPINNER_MODE: AtomicBool = AtomicBool::new(true);
static ASSUME_YES: AtomicBool = AtomicBool::new(false);

/// Initialize the output context with the given flags
pub fn init(quiet: bool, verbose: bool) {
pub fn init(quiet: bool, verbose: bool, no_color: bool, no_spinner: bool, assume_yes: bool) {
QUIET_MODE.store(quiet, Ordering::SeqCst);
VERBOSE_MODE.store(verbose, Ordering::SeqCst);
ASSUME_YES.store(assume_yes, Ordering::SeqCst);

let stdout_is_tty = std::io::stdout().is_terminal();
let color_enabled = !no_color
&& stdout_is_tty
&& std::env::var_os("NO_COLOR").is_none();
COLOR_MODE.store(color_enabled, Ordering::SeqCst);
colored::control::set_override(color_enabled);

let spinner_enabled = !no_spinner
&& stdout_is_tty
&& std::env::var_os("CI").is_none()
&& std::env::var_os("NO_COLOR").is_none();
SPINNER_MODE.store(spinner_enabled, Ordering::SeqCst);
}

/// Returns true if quiet mode is enabled
Expand All @@ -23,10 +41,25 @@ pub fn is_verbose() -> bool {
VERBOSE_MODE.load(Ordering::SeqCst)
}

/// Returns true if colored output is enabled
pub fn use_color() -> bool {
COLOR_MODE.load(Ordering::SeqCst)
}

/// Returns true if spinners are enabled
pub fn use_spinner() -> bool {
SPINNER_MODE.load(Ordering::SeqCst)
}

/// Returns true if prompts should be auto-confirmed
pub fn assume_yes() -> bool {
ASSUME_YES.load(Ordering::SeqCst)
}

/// Create a spinner progress bar for long-running operations
/// Returns None if quiet mode is enabled
pub fn spinner(message: &str) -> Option<ProgressBar> {
if is_quiet() {
if is_quiet() || !use_spinner() {
return None;
}

Expand Down
20 changes: 14 additions & 6 deletions src/core/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,24 +61,32 @@ pub fn table(headers: &[&str], rows: &[Vec<String>]) {
return;
}

println!();

if headers.is_empty() {
return;
}

if rows.is_empty() {
info("No results.");
return;
}

println!();

let mut table = Table::new();
let use_color = context::use_color();
table.set_header(headers.iter().map(|header| {
Cell::new(*header)
.add_attribute(Attribute::Bold)
.fg(Color::Cyan)
let mut cell = Cell::new(*header);
if use_color {
cell = cell.add_attribute(Attribute::Bold).fg(Color::Cyan);
}
cell
}));

for row in rows {
let mut cells = Vec::with_capacity(row.len());
for (idx, value) in row.iter().enumerate() {
let cell = Cell::new(value);
if idx == 0 {
if use_color && idx == 0 {
cells.push(cell.fg(Color::Yellow));
} else {
cells.push(cell);
Expand Down
Loading