Co-authored-by: Snir Shechter <snir.shechter@coralogix.com>
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 withpub mod api;+ fan-out, merge, render)src/commands/mod.rs(addpub 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
Deserializewith#[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 behaviorrender::render_tablehandles 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 oninclude_profile.- Toon output is command-owned - each command calls
toon_encodedirectly 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.
- Tag the subcommand doc comment with
[requires --yes]:
/// Delete an item [requires --yes].
Delete {
/// Item ID.
id: String,
},
- 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:
- Create your API and command modules as usual (
src/commands/your_sub/api.rs,src/commands/your_sub/mod.rs) - Add a variant to the wrapper enum (e.g.,
IamCmd,NotificationsCmd) inmain.rs:
#[derive(Subcommand)]
enum IamCmd {
// ... existing variants ...
/// Manage your-sub resources.
YourSub {
#[command(subcommand)]
cmd: YourSubCmd,
},
}
- Add the leaf subcommand enum (
YourSubCmd) with its operations (list, get, etc.) - Add dispatch in the existing wrapper's match arm
- Update the wrapper's
after_helpin theCommandsenum 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)