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

764 lines
25 KiB
Markdown

# Adding a Command
> Step-by-step guide for adding a new command to `cx`. Read [architecture.md](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`:
```rust
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`:
```rust
pub mod your_domain;
```
### Step 3: CLI wiring
In `src/main.rs`, add a variant to the `Commands` enum:
```rust
#[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):
```rust
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`:
```rust
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`:
```rust
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`:
```rust
pub mod your_domain;
```
### Step 4: CLI wiring
In `src/main.rs`, define the subcommand enum and add to `Commands`:
```rust
#[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:
```rust
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:
```rust
#[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]`:
```rust
/// Delete an item [requires --yes].
Delete {
/// Item ID.
id: String,
},
```
2. Add a `confirm_destructive()` call in the dispatch match arm, before the handler:
```rust
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`:
```rust
#[derive(Subcommand)]
enum IamCmd {
// ... existing variants ...
/// Manage your-sub resources.
YourSub {
#[command(subcommand)]
cmd: YourSubCmd,
},
}
```
3. **Add the leaf subcommand enum** (`YourSubCmd`) with its operations (list, get, etc.)
4. **Add dispatch** in the existing wrapper's match arm
5. **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.
```rust
// 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`.
```rust
// 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.
```rust
// 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`:
```rust
#[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
```bash
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](development.md) for the full e2e setup.
### Manual smoke testing
After building (`cargo build`), do at least one human-in-the-loop pass:
```bash
# 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](adding-a-skill.md)** 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:
```markdown
## 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)
```