Files
amitcoralogix f688e2fadc AIAP-1095 | Renaming agents -> toon (#194)
Co-authored-by: Snir Shechter <snir.shechter@coralogix.com>
2026-08-13 15:35:23 +03:00

25 KiB

Adding a Command

Step-by-step guide for adding a new command to cx. Read architecture.md first for the execution flow and design decisions behind this structure.

Choose your archetype

Every command falls into one of two patterns:

Archetype When to use Reference implementation
A: DataPrime-based Querying logs, spans, or any DataPrime source logs (src/commands/logs/mod.rs)
B: REST-based Wrapping a Coralogix REST API alerts (src/commands/alerts/mod.rs, src/commands/alerts/api.rs)

Important: All API integrations must use REST (HTTP). Do not use gRPC - the CLI is HTTP-only by design.

DataPrime commands delegate to the shared pipeline and require minimal code. REST commands manage their own fan-out, merge, and render - more code, but more control.


Archetype A: DataPrime-based command

Use this when your command queries a DataPrime source. The shared pipeline in commands::dataprime handles fan-out, merge, render, and spilling - you provide only a text renderer and a thin run() wrapper.

Files to create/modify:

  • src/commands/your_domain/mod.rs (new)
  • src/commands/mod.rs (add module)
  • src/main.rs (CLI definition + dispatch)

Step 1: Command module

Create src/commands/your_domain/mod.rs:

use std::sync::Arc;

use anyhow::Result;
use colored::Colorize;
use serde_json::Value;

use crate::commands::dataprime::MergedResults;
use crate::config::OutputFormat;
use crate::execution::ExecutionTarget;
use crate::Tier;

// ── Text renderer ──────────────────────────────────────────────────

pub fn render_your_domain_text(merged: &MergedResults) -> Result<()> {
    if merged.rows.is_empty() {
        println!("{}", "No results found.".yellow());
        return Ok(());
    }

    // Aggregate queries return raw JSON - no custom rendering.
    if merged.is_aggregate {
        for row in &merged.rows {
            println!("{}", serde_json::to_string_pretty(row)?);
        }
        return Ok(());
    }

    for row in &merged.rows {
        // Multi-profile: prefix each line with [profile_name]
        let profile = if merged.include_profile {
            row.get("profile")
                .and_then(|v| v.as_str())
                .map(|s| format!("[{s}] "))
                .unwrap_or_default()
        } else {
            String::new()
        };

        // Extract your domain-specific fields from the JSON row.
        // Use row.pointer("/path/to/field") for nested access.
        let field_a = row.pointer("/metadata/some_field")
            .and_then(|v| v.as_str())
            .unwrap_or("-");

        println!("{}{}", profile.dimmed(), field_a);
    }

    Ok(())
}

// ── Orchestrator ───────────────────────────────────────────────────

#[allow(clippy::too_many_arguments)]
pub async fn run(
    targets: &[Arc<ExecutionTarget>],
    query: &str,
    start: &str,
    end: &str,
    limit: u32,
    tier: Tier,
    output: OutputFormat,
    max_direct: Option<usize>,
    temp_dir: &str,
) -> Result<()> {
    super::dataprime::run_query(
        targets,
        query,
        "your_source",  // DataPrime source name (e.g., "logs", "spans")
        start,
        end,
        limit,
        tier,
        output,
        max_direct,
        temp_dir,
        Some(render_your_domain_text),
    )
    .await
}

The text renderer signature must be fn(&MergedResults) -> Result<()>. The shared pipeline calls it only for OutputFormat::Text - JSON and Toon output are handled generically.

Reference: src/commands/logs/mod.rs - the entire module is ~130 lines.

Step 2: Register the module

Add your module to src/commands/mod.rs:

pub mod your_domain;

Step 3: CLI wiring

In src/main.rs, add a variant to the Commands enum:

#[derive(Subcommand)]
enum Commands {
    // ... existing variants ...

    /// Query your-domain data using DataPrime syntax.
    #[command(after_help = "\
Examples:
  cx your-domain 'filter $d.field == \"value\"'
  cx your-domain 'filter $m.severity == ERROR' --start now-6h")]
    YourDomain {
        /// DataPrime query string.
        query: String,

        #[arg(long, default_value = "now-1h")]
        start: String,

        #[arg(long, default_value = "now")]
        end: String,

        #[arg(long, default_value_t = 100)]
        limit: u32,

        #[arg(long, default_value = "frequent")]
        tier: Tier,
    },
}

Add the dispatch match arm (after config resolution, inside the match cli.command block):

Commands::YourDomain {
    query,
    start,
    end,
    limit,
    tier,
} => {
    commands::your_domain::run(
        &targets, &query, &start, &end, limit, tier, output, max_direct, &temp_dir,
    )
    .await?;
}

That's it for a DataPrime command. The shared pipeline handles fan-out, merge, toon output, and spilling.


Archetype B: REST-based command

Use this when your command wraps a Coralogix REST API. You'll build the full pipeline: API client, fan-out, merge, and render.

Files to create/modify:

  • src/commands/your_domain/api.rs (new - API types + client)
  • src/commands/your_domain/mod.rs (new - handler with pub mod api; + fan-out, merge, render)
  • src/commands/mod.rs (add pub mod your_domain;)
  • src/main.rs (CLI definition + dispatch)

Step 1: API module

Create src/commands/your_domain/api.rs:

use serde::Deserialize;
use serde_json::Value;

use crate::error::Result;

use crate::api_client::CxClient;

// ── Response types ─────────────────────────────────────────────────

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct YourItem {
    pub id: Option<String>,
    pub name: Option<String>,
    // Add fields matching the API's JSON response.
    // Use Option<T> for fields that may be absent.
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListResponse {
    #[serde(default)]
    pub items: Vec<YourItem>,
}

// ── API client ─────────────────────────────────────────────────────

const BASE_PATH: &str = "/mgmt/openapi/5/your-domain/v1";

pub struct YourDomainApi<'a> {
    client: &'a CxClient,
}

impl<'a> YourDomainApi<'a> {
    pub fn new(client: &'a CxClient) -> Self {
        Self { client }
    }

    pub async fn list(&self) -> Result<ListResponse> {
        self.client.get(BASE_PATH, &[]).await
    }

    pub async fn get(&self, id: &str) -> Result<Value> {
        let path = format!("{BASE_PATH}/{id}");
        self.client.get(&path, &[]).await
    }
}

// ── Tests ──────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn deserialize_list_response() {
        let json = json!({
            "items": [
                { "id": "abc-123", "name": "First Item" },
                { "id": "def-456", "name": "Second Item" }
            ]
        });
        let resp: ListResponse = serde_json::from_value(json).unwrap();
        assert_eq!(resp.items.len(), 2);
        assert_eq!(resp.items[0].id.as_deref(), Some("abc-123"));
    }

    #[test]
    fn deserialize_empty_list() {
        let json = json!({});
        let resp: ListResponse = serde_json::from_value(json).unwrap();
        assert!(resp.items.is_empty());
    }
}

Key conventions:

  • Response types derive Deserialize with #[serde(rename_all = "camelCase")]
  • Use #[serde(default)] on Vec fields so missing keys deserialize to empty
  • The API struct borrows &CxClient - use the appropriate method (get, post, post_raw, post_empty) based on the endpoint
  • Always write deserialization tests against realistic JSON fixtures

Reference: src/commands/alerts/api.rs - full example with list, get, create, and state-change endpoints.

Step 2: Command module

Create src/commands/your_domain/mod.rs:

use std::sync::Arc;

use anyhow::Result;
use colored::Colorize;
use serde_json::{json, Value};
use toon_format::encode_default as toon_encode;

pub mod api;

use api::YourDomainApi;

use crate::config::OutputFormat;
use crate::execution::{fan_out, ExecutionTarget};
use crate::render;

// ── Subcommand runner ──────────────────────────────────────────────

pub async fn run_list(
    targets: &[Arc<ExecutionTarget>],
    output: OutputFormat,
) -> Result<()> {
    eprintln!("{}", "Fetching items...".dimmed());

    let include_profile = targets.len() > 1;

    // 1. Fan-out: call the API across all profiles concurrently.
    let per_profile = fan_out(targets, |t| async move {
        let api = YourDomainApi::new(&t.client);
        Ok(api.list().await?)
    })
    .await;

    // 2. Merge: collect results, print per-profile errors to stderr.
    let mut all_json: Vec<Value> = Vec::new();
    let mut all_items: Vec<(String, String, String)> = Vec::new(); // (profile, id, name)
    for (profile, result) in per_profile {
        match result {
            Ok(resp) => {
                for item in resp.items {
                    let id = item.id.clone().unwrap_or_default();
                    let name = item.name.clone().unwrap_or_default();
                    let mut j = json!({ "id": id, "name": name });
                    if include_profile {
                        j.as_object_mut().unwrap()
                            .insert("profile".to_string(), Value::String(profile.clone()));
                    }
                    all_json.push(j);
                    all_items.push((profile.clone(), id, name));
                }
            }
            Err(e) => eprintln!("{}", format!("error from profile '{profile}': {e:#}").red()),
        }
    }

    // 3. Render: match on output format.
    match output {
        OutputFormat::Json => render::render_json(&all_json)?,
        OutputFormat::Toon => {
            let toon =
                toon_encode(&all_json).map_err(|e| anyhow::anyhow!("TOON encoding failed: {e}"))?;
            println!("{toon}");
        }
        OutputFormat::Text => {
            if all_items.is_empty() {
                render::print_no_results("No items found.");
                return Ok(());
            }
            let rows: Vec<Vec<String>> = all_items
                .iter()
                .map(|(profile, id, name)| {
                    vec![profile.clone(), id.clone(), name.clone()]
                })
                .collect();
            render::render_table(&["ID", "Name"], rows, include_profile);
        }
    }

    Ok(())
}

Key patterns to follow:

  • include_profile = targets.len() > 1 - this boolean controls all multi-profile behavior
  • render::render_table handles the Profile column automatically - pass headers without "Profile", and put the profile name as the first element of each row. The helper conditionally includes/excludes it based on include_profile.
  • Toon output is command-owned - each command calls toon_encode directly after any post-processing it needs
  • Fan-out errors are non-fatal - print to stderr, continue with successful profiles
  • Status messages go to stderr - use eprintln! so they don't pollute piped output

Reference: src/commands/dashboards/mod.rs - clean example with list and get subcommands using render::* helpers.

Step 3: Register the command module

Add to src/commands/mod.rs:

pub mod your_domain;

Step 4: CLI wiring

In src/main.rs, define the subcommand enum and add to Commands:

#[derive(Subcommand)]
enum YourDomainCmd {
    /// List all items.
    List,
    /// Get a single item by ID.
    Get {
        /// Item ID.
        item_id: String,
    },
}

#[derive(Subcommand)]
enum Commands {
    // ... existing variants ...

    /// Manage your-domain resources.
    YourDomain {
        #[command(subcommand)]
        cmd: YourDomainCmd,
    },
}

Add the dispatch match arm:

Commands::YourDomain { cmd } => match cmd {
    YourDomainCmd::List => {
        commands::your_domain::run_list(&targets, output).await?;
    }
    YourDomainCmd::Get { item_id } => {
        commands::your_domain::run_get(&targets, &item_id, output).await?;
    }
},

Step 6: Update the help display

The Cli struct uses a custom after_help string (not Clap's next_help_heading or flatten) to display grouped command categories. After adding a command to the Commands enum, add a line for it in the after_help string under the appropriate category group:

#[command(
    help_template = "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nGlobal Options:\n{options}",
    after_help = "\
Query:
  logs               Query logs using DataPrime syntax
  ...
Data Pipeline:
  parsing-rules      Manage log parsing rules
  your-domain        Your domain description        <-- add here
  ..."
)]

Place the command in the category that best fits its purpose. See main.rs for the full list of categories: Query, Observe, Detect & Respond, Notifications, Data Pipeline, Cost & Storage, Integrations, Access, Agent, Local.

Step 7: Guard destructive operations (REST archetype)

If your command has write operations (create, update, delete, enable, disable, etc.), guard them with a confirmation prompt using confirm_destructive() from src/safety.rs. This prevents accidental execution in interactive terminals and blocks non-interactive callers unless they pass --yes.

  1. Tag the subcommand doc comment with [requires --yes]:
/// Delete an item [requires --yes].
Delete {
    /// Item ID.
    id: String,
},
  1. Add a confirm_destructive() call in the dispatch match arm, before the handler:
YourDomainCmd::Delete { id } => {
    confirm_destructive(&format!("Delete item '{id}'?"), yes, agent_mode)?;
    commands::your_domain::run_delete(&targets, &id).await?;
}

The yes variable is the global --yes flag, already available in the match scope. Read-only operations (list, get, search) should NOT have confirmation prompts.

If your command is risky enough to warrant a (risky) tag on the parent command in cx --help, add it to the after_help string (e.g., your-domain (risky)).


Adding a subcommand to a wrapper command

Some commands group multiple related domains under a single CLI entry point using a wrapper enum. Examples: iam (api-keys, roles, scopes, users, groups, ip-access), notifications (connectors, routers, presets, test), integrations (extensions, contextual-data).

If your new functionality belongs under an existing wrapper, you do not create a new top-level command. Instead:

  1. Create your API and command modules as usual (src/commands/your_sub/api.rs, src/commands/your_sub/mod.rs)
  2. Add a variant to the wrapper enum (e.g., IamCmd, NotificationsCmd) in main.rs:
#[derive(Subcommand)]
enum IamCmd {
    // ... existing variants ...

    /// Manage your-sub resources.
    YourSub {
        #[command(subcommand)]
        cmd: YourSubCmd,
    },
}
  1. Add the leaf subcommand enum (YourSubCmd) with its operations (list, get, etc.)
  2. Add dispatch in the existing wrapper's match arm
  3. Update the wrapper's after_help in the Commands enum variant to include the new sub-domain in its examples

The wrapper variant in Commands already appears in the top-level after_help, so no change is needed there unless the wrapper's description should be updated.

Reference: See IamCmd in main.rs for a wrapper with seven sub-domains, or NotificationsCmd for a wrapper with four.


Testing

Every new command must add tests at three layers: unit, integration, and e2e. Each layer catches different categories of regressions - skipping any of them leaves real holes.

Layer Location What it verifies Network
Unit src/**/<file>.rs #[cfg(test)] blocks Pure logic - deserialization, formatting helpers, data transforms None
Integration tests/<domain>.rs (wiremock) Command runner end-to-end with mocked HTTP responses None
E2E tests/e2e/<command>/mod.rs (assert_cmd) Real cx binary runs against the Coralogix test team Real

Layer 1 - Unit tests

Deserialization tests (REST archetype, mandatory)

Every API module must have deserialization tests. These verify that your response types correctly parse the actual API JSON shape.

// in src/commands/your_domain/api.rs
#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn deserialize_list_response() {
        let json = json!({
            "items": [
                { "id": "abc-123", "name": "Test Item" }
            ]
        });
        let resp: ListResponse = serde_json::from_value(json).unwrap();
        assert_eq!(resp.items.len(), 1);
    }

    #[test]
    fn deserialize_empty_response() {
        let json = json!({});
        let resp: ListResponse = serde_json::from_value(json).unwrap();
        assert!(resp.items.is_empty());
    }
}

Test happy-path responses and edge cases: empty lists, missing optional fields, fallback values. These are the cases that break in production.

Helper/formatting tests (any archetype, when applicable)

If your command module has non-trivial mapping logic - building tabular rows, transforming JSON shapes, parsing user input - add unit tests for those helpers. See src/commands/metrics/tests.rs for an example.

Layer 2 - Integration tests (wiremock)

Add tests/<your_domain>.rs using wiremock to spin up a fake Coralogix API and call your command runner directly. This catches regressions in fan-out, merge, rendering, and the wiring between command and API layer.

Reference: tests/alerts/main.rs, tests/metrics/main.rs, tests/search_fields/main.rs.

// tests/your_domain/main.rs
mod common;

use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

use coralogix_cli::commands::your_domain::run_list;
use coralogix_cli::config::OutputFormat;

#[tokio::test]
async fn list_returns_items_from_mock() {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/mgmt/openapi/5/your-domain/v1"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "items": [{ "id": "abc-123", "name": "Test" }]
        })))
        .expect(1)
        .mount(&server)
        .await;

    let targets = vec![common::test_target("test-profile", &server.uri())];
    run_list(&targets, OutputFormat::Json)
        .await
        .expect("run_list should succeed");
}

Cover at minimum: happy-path list/get, an empty response, a --name/filter case if your command supports one, and the JSON output path.

Layer 3 - E2E tests (real test team)

Add a sanity test in tests/e2e/<your_domain>/mod.rs that invokes the compiled cx binary against a real Coralogix test team. The goal is only to verify that the command runs end-to-end: exits 0, produces non-empty stdout, and (for -o json) emits valid JSON. Don't assert on output content - test team data drifts.

All e2e tests are #[ignore]d, so they don't run in the default cargo test. CI invokes them via a separate workflow.

// tests/e2e/your_domain/mod.rs
use std::sync::OnceLock;

use crate::harness;

#[test]
#[ignore]
fn your_domain_list() {
    if harness::require_creds("your_domain_list").is_none() {
        return;
    }
    harness::run_ok_json(&["your-domain", "list", "-o", "json"]);
}

#[test]
#[ignore]
fn your_domain_get() {
    if harness::require_creds("your_domain_get").is_none() {
        return;
    }
    let Some(id) = discover_your_domain_id() else {
        eprintln!("[e2e] skipping your_domain_get: no items on test team");
        return;
    };
    harness::run_ok_json(&["your-domain", "get", &id, "-o", "json"]);
}

/// Discover an id from `your-domain list -o json`. Cached so multiple
/// tests don't each pay for the list call.
fn discover_your_domain_id() -> Option<String> {
    static CACHE: OnceLock<Option<String>> = OnceLock::new();
    CACHE
        .get_or_init(|| {
            let stdout = harness::run_ok(&["your-domain", "list", "-o", "json"]);
            let v = harness::parse_json(&stdout)?;
            v.as_array()?
                .iter()
                .filter_map(|item| item.get("id").and_then(|x| x.as_str()))
                .next()
                .map(String::from)
        })
        .clone()
}

Then declare the module in tests/e2e.rs:

#[path = "e2e/your_domain/mod.rs"]
mod your_domain;

Discovery helpers stay local to each test module - see discover_alert_id in tests/e2e/alerts/mod.rs for the pattern. They should cache via OnceLock and skip (return None) when the test team has no data, not panic.

Do not exercise mutating commands in e2e (create/delete/enable/ disable) until there's a paired-undo plan - they touch shared test team state. Use a comment to mark them as deliberately uncovered, like the existing block at the bottom of tests/e2e/alerts/mod.rs.

Running the suites

cargo test                                              # unit + integration
cargo test --test e2e -- --ignored --test-threads=1     # e2e (needs CX_API_KEY)
cargo clippy                                            # lint
cargo fmt --check                                       # format check

See development.md for the full e2e setup.

Manual smoke testing

After building (cargo build), do at least one human-in-the-loop pass:

# DataPrime commands:
cx your-domain 'filter $d.field == "value"'
cx your-domain 'filter $d.field == "value"' -o json
cx your-domain 'filter $d.field == "value"' -o toon

# REST commands:
cx your-domain list
cx your-domain list -o json
cx your-domain get <id>

# Multi-profile:
cx -p prod -p staging your-domain list

User-facing skill (required)

Every command must be covered by a skill in skills/. This can be a dedicated skill for the command, or a workflow skill that covers multiple related commands (e.g., cx-cost-optimization covers cx usage, cx tco, cx retentions, and cx archive). Check the existing workflow skills before creating a new one - your command may already be covered.

See Adding a Skill for the complete guide covering directory structure, frontmatter conventions, trigger phrases, reference files, and templates for both single-command and workflow skills.

Reference: skills/cx-alerts/SKILL.md (single-command) and skills/cx-cost-optimization/SKILL.md (workflow skill) - full examples.


PR checklist

Copy this into your PR description:

## Checklist

### API layer (REST archetype only)
- [ ] `src/commands/your_domain/api.rs` - response types with `#[derive(Deserialize)]`
- [ ] `src/commands/your_domain/api.rs` - `YourDomainApi` struct with methods
- [ ] `src/commands/your_domain/api.rs` - deserialization tests for all response types
- [ ] `src/commands/your_domain/mod.rs` - `pub mod api;` declared at the top

### Command layer
- [ ] `src/commands/your_domain/mod.rs` - subcommand runner(s) with fan-out/merge/render
- [ ] `src/commands/your_domain/mod.rs` - dual row structs for text output (multi-profile + single)
- [ ] `src/commands/your_domain/mod.rs` - all three output formats handled (Text, Json, Toon)
- [ ] `src/commands/mod.rs` - `pub mod your_domain;` registered

### CLI wiring
- [ ] `src/main.rs` - `Commands` enum variant added
- [ ] `src/main.rs` - subcommand enum added (REST) or args defined (DataPrime)
- [ ] `src/main.rs` - dispatch match arm added
- [ ] `src/main.rs` - destructive subcommands (create/update/delete) guarded with `confirm_destructive()` and tagged `[requires --yes]` in their doc comment (see `src/safety.rs`)

### Tests
- [ ] **Unit:** API deserialization tests in `src/commands/your_domain/api.rs` (REST)
- [ ] **Unit:** helper/formatting tests if the command module has non-trivial logic
- [ ] **Integration:** `tests/your_domain/main.rs` covering happy-path, empty response, and any filters via wiremock
- [ ] **E2E:** sanity test(s) in `tests/e2e/your_domain/mod.rs`, declared in `tests/e2e.rs` via `#[path]`
- [ ] **E2E:** local `discover_*` fn added to `tests/e2e/<your_domain>/mod.rs` if a subcommand needs an ID/name from the test team

### User-facing skill
- [ ] Command is covered by a skill in `skills/` (new or existing workflow skill)
- [ ] `scripts/verify-skills.sh` - all skills pass

### Verification
- [ ] `cargo build` succeeds
- [ ] `cargo test` passes (unit + integration)
- [ ] `cargo test --test e2e -- --ignored --test-threads=1` passes against the test team
- [ ] `cargo clippy` clean
- [ ] `cargo fmt --check` clean
- [ ] Manual smoke test: text, json, and toon output
- [ ] Manual smoke test: multi-profile (if applicable)