From 0e4ac4bb84e0cea318c9af13fff0d5ac12eb2731 Mon Sep 17 00:00:00 2001 From: Teddy Kahwaji Date: Mon, 13 Jul 2026 14:52:57 -0400 Subject: [PATCH 1/2] feat(onboarding): add pup commands for the agentic onboarding API Wrap the three public agentic-onboarding-api routes so an AI assistant can run `pup onboarding ...` instead of raw HTTP calls to fetch onboarding skills and record sessions. - skills list / skills get -> GET /api/v2/onboarding/skills[/{id}] - sessions create -> POST /api/v2/onboarding/sessions (JSON:API envelope) - Optional auth: routes are OpenAuth, so credentials are attached when present but never required (unlike client::apply_auth) Adds src/commands/onboarding.rs with 7 tests covering happy paths, org_id forwarding, and validation/error cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/mod.rs | 1 + src/commands/onboarding.rs | 304 +++++++++++++++++++++++++++++++++++++ src/main.rs | 134 ++++++++++++++++ 3 files changed, 439 insertions(+) create mode 100644 src/commands/onboarding.rs diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 83c77242..f6d7c5b5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -63,6 +63,7 @@ pub mod network; pub mod notebooks; pub mod obs_pipelines; pub mod on_call; +pub mod onboarding; pub mod organizations; pub mod processes; pub mod product_analytics; diff --git a/src/commands/onboarding.rs b/src/commands/onboarding.rs new file mode 100644 index 00000000..402a98d0 --- /dev/null +++ b/src/commands/onboarding.rs @@ -0,0 +1,304 @@ +use anyhow::Result; + +use crate::client::HttpError; +use crate::config::Config; +use crate::formatter; +use crate::useragent; + +const SKILLS_PATH: &str = "/api/v2/onboarding/skills"; +const SESSIONS_PATH: &str = "/api/v2/onboarding/sessions"; + +const VALID_STATUSES: &[&str] = &["in_progress", "completed", "failed", "abandoned"]; +const VALID_INTENTS: &[&str] = &["explore", "install", "reference"]; + +/// List the available Datadog onboarding skills (`GET /api/v2/onboarding/skills`). +pub async fn skills_list(cfg: &Config, org_id: Option) -> Result<()> { + let org = org_id.map(|o| o.to_string()); + let query: Vec<(&str, &str)> = match &org { + Some(o) => vec![("org_id", o.as_str())], + None => vec![], + }; + let resp = get(cfg, SKILLS_PATH, &query).await?; + formatter::output(cfg, &resp) +} + +/// Fetch a single onboarding skill and its content +/// (`GET /api/v2/onboarding/skills/{skill_id}`). +pub async fn skills_get( + cfg: &Config, + skill_id: &str, + intent: Option<&str>, + onboarding_run_id: Option<&str>, + org_id: Option, +) -> Result<()> { + if let Some(intent) = intent { + if !VALID_INTENTS.contains(&intent) { + anyhow::bail!( + "invalid intent '{intent}'; expected one of: {}", + VALID_INTENTS.join(", ") + ); + } + } + + let org = org_id.map(|o| o.to_string()); + let mut query: Vec<(&str, &str)> = Vec::new(); + if let Some(intent) = intent { + query.push(("intent", intent)); + } + if let Some(run_id) = onboarding_run_id { + query.push(("onboarding_run_id", run_id)); + } + if let Some(o) = &org { + query.push(("org_id", o.as_str())); + } + + let path = format!("{SKILLS_PATH}/{skill_id}"); + let resp = get(cfg, &path, &query).await?; + formatter::output(cfg, &resp) +} + +/// Record an onboarding session outcome (`POST /api/v2/onboarding/sessions`). +pub async fn sessions_create( + cfg: &Config, + session_id: &str, + skill_ids: &[String], + summary: &str, + status: &str, + org_id: Option, +) -> Result<()> { + if !VALID_STATUSES.contains(&status) { + anyhow::bail!( + "invalid status '{status}'; expected one of: {}", + VALID_STATUSES.join(", ") + ); + } + + let mut attributes = serde_json::json!({ + "skill_ids": skill_ids, + "summary": summary, + "status": status, + }); + if let Some(org_id) = org_id { + if let Some(obj) = attributes.as_object_mut() { + obj.insert("org_id".to_string(), serde_json::json!(org_id)); + } + } + + let body = serde_json::json!({ + "data": { + "type": "onboarding_session", + "id": session_id, + "attributes": attributes, + }, + }); + + let resp = post(cfg, SESSIONS_PATH, body).await?; + formatter::output(cfg, &resp) +} + +// Onboarding routes are OpenAuth, so unlike `client::apply_auth` this attaches +// credentials when present but never fails without them. +fn apply_optional_auth(mut req: reqwest::RequestBuilder, cfg: &Config) -> reqwest::RequestBuilder { + if let Some(token) = &cfg.access_token { + req = req.header("Authorization", format!("Bearer {token}")); + } else if let (Some(api_key), Some(app_key)) = (&cfg.api_key, &cfg.app_key) { + req = req + .header("DD-API-KEY", api_key.as_str()) + .header("DD-APPLICATION-KEY", app_key.as_str()); + } + req +} + +async fn get(cfg: &Config, path: &str, query: &[(&str, &str)]) -> Result { + let url = format!("{}{}", cfg.api_base_url(), path); + let client = reqwest::Client::new(); + let mut req = client.get(&url); + if !query.is_empty() { + req = req.query(query); + } + req = apply_optional_auth(req, cfg); + let resp = req + .header("Accept", "application/vnd.api+json") + .header("User-Agent", useragent::get()) + .send() + .await?; + read_json(resp, "GET", &url).await +} + +async fn post(cfg: &Config, path: &str, body: serde_json::Value) -> Result { + let url = format!("{}{}", cfg.api_base_url(), path); + let client = reqwest::Client::new(); + let mut req = client.post(&url); + req = apply_optional_auth(req, cfg); + let resp = req + .header("Content-Type", "application/vnd.api+json") + .header("Accept", "application/vnd.api+json") + .header("User-Agent", useragent::get()) + .json(&body) + .send() + .await?; + read_json(resp, "POST", &url).await +} + +async fn read_json(resp: reqwest::Response, method: &str, url: &str) -> Result { + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(HttpError { + status: status.as_u16(), + method: method.to_string(), + url: url.to_string(), + body, + } + .into()); + } + Ok(resp.json().await?) +} + +#[cfg(test)] +mod tests { + use crate::test_support::*; + + #[tokio::test] + async fn skills_list_ok() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock = server + .mock("GET", "/api/v2/onboarding/skills") + .with_status(200) + .with_header("content-type", "application/vnd.api+json") + .with_body(r#"{"data":[]}"#) + .create_async() + .await; + let result = super::skills_list(&cfg, None).await; + assert!(result.is_ok(), "skills list failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn skills_list_forwards_org_id() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let mock = server + .mock("GET", "/api/v2/onboarding/skills") + .match_query(mockito::Matcher::UrlEncoded("org_id".into(), "42".into())) + .with_status(200) + .with_header("content-type", "application/vnd.api+json") + .with_body(r#"{"data":[]}"#) + .create_async() + .await; + let result = super::skills_list(&cfg, Some(42)).await; + assert!(result.is_ok(), "skills list failed: {:?}", result.err()); + mock.assert_async().await; + cleanup_env(); + } + + #[tokio::test] + async fn skills_get_ok_with_intent() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let mock = server + .mock("GET", "/api/v2/onboarding/skills/aws-integration-setup") + .match_query(mockito::Matcher::UrlEncoded( + "intent".into(), + "install".into(), + )) + .with_status(200) + .with_header("content-type", "application/vnd.api+json") + .with_body(r#"{"data":{"id":"aws-integration-setup"}}"#) + .create_async() + .await; + let result = + super::skills_get(&cfg, "aws-integration-setup", Some("install"), None, None).await; + assert!(result.is_ok(), "skills get failed: {:?}", result.err()); + mock.assert_async().await; + cleanup_env(); + } + + #[tokio::test] + async fn skills_get_rejects_invalid_intent() { + let _lock = lock_env().await; + let server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let result = super::skills_get(&cfg, "some-skill", Some("bogus"), None, None).await; + let err = result.unwrap_err().to_string(); + assert!(err.contains("invalid intent"), "got: {err}"); + cleanup_env(); + } + + #[tokio::test] + async fn skills_get_surfaces_not_found() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock = server + .mock("GET", mockito::Matcher::Any) + .with_status(404) + .with_header("content-type", "application/vnd.api+json") + .with_body(r#"{"errors":[{"detail":"Skill not found: nope"}]}"#) + .create_async() + .await; + let result = super::skills_get(&cfg, "nope", None, None, None).await; + let err = result.unwrap_err().to_string(); + assert!(err.contains("404"), "expected 404 in error, got: {err}"); + cleanup_env(); + } + + #[tokio::test] + async fn sessions_create_ok() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let mock = server + .mock("POST", "/api/v2/onboarding/sessions") + .match_body(mockito::Matcher::PartialJson(serde_json::json!({ + "data": { + "type": "onboarding_session", + "id": "run-123", + "attributes": { + "status": "completed", + "skill_ids": ["aws-integration-setup"], + }, + }, + }))) + .with_status(201) + .with_header("content-type", "application/vnd.api+json") + .with_body(r#"{"data":{"id":"run-123"}}"#) + .create_async() + .await; + let result = super::sessions_create( + &cfg, + "run-123", + &["aws-integration-setup".to_string()], + "Set up the AWS integration", + "completed", + None, + ) + .await; + assert!(result.is_ok(), "sessions create failed: {:?}", result.err()); + mock.assert_async().await; + cleanup_env(); + } + + #[tokio::test] + async fn sessions_create_rejects_invalid_status() { + let _lock = lock_env().await; + let server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let result = super::sessions_create( + &cfg, + "run-123", + &["aws-integration-setup".to_string()], + "summary", + "done", + None, + ) + .await; + let err = result.unwrap_err().to_string(); + assert!(err.contains("invalid status"), "got: {err}"); + cleanup_env(); + } +} diff --git a/src/main.rs b/src/main.rs index bdae9ab0..df0f9e91 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2109,6 +2109,36 @@ enum Commands { #[command(subcommand)] action: OnCallActions, }, + /// Fetch Datadog agentic onboarding skills and record sessions + /// + /// Thin client over the Datadog agentic onboarding API + /// (`/api/v2/onboarding/...`). Lets an AI assistant discover onboarding + /// skills, fetch their setup instructions, and record session outcomes by + /// running `pup` instead of raw HTTP calls. + /// + /// COMMAND GROUPS: + /// skills Discover and fetch onboarding skills + /// sessions Record onboarding session outcomes + /// + /// EXAMPLES: + /// # List available onboarding skills + /// pup onboarding skills list + /// + /// # Fetch a skill's setup instructions with the caller's intent + /// pup onboarding skills get aws-integration-setup --intent install + /// + /// # Record a completed onboarding session + /// pup onboarding sessions create --session-id run-123 \ + /// --skill-id aws-integration-setup --summary "Set up AWS" --status completed + /// + /// AUTHENTICATION: + /// Works unauthenticated (these routes are public). Attaches OAuth2 or API + /// keys when configured so the request is attributed to your org. + #[command(verbatim_doc_comment)] + Onboarding { + #[command(subcommand)] + action: OnboardingActions, + }, /// Manage organization settings /// /// Manage organization-level settings and configuration. @@ -6461,6 +6491,67 @@ enum CicdFlakyTestActions { }, } +// ---- Onboarding ---- +#[derive(Subcommand)] +enum OnboardingActions { + /// Discover and fetch onboarding skills + Skills { + #[command(subcommand)] + action: OnboardingSkillsActions, + }, + /// Record onboarding session outcomes + Sessions { + #[command(subcommand)] + action: OnboardingSessionsActions, + }, +} + +#[derive(Subcommand)] +enum OnboardingSkillsActions { + /// List the available onboarding skills + List { + /// Org ID hint for feature-flag evaluation + #[arg(long)] + org_id: Option, + }, + /// Fetch a skill and its setup instructions + Get { + /// Skill ID (as returned by `skills list`) + skill_id: String, + /// Caller intent: explore, install, or reference + #[arg(long)] + intent: Option, + /// Onboarding run ID to correlate this fetch with a session + #[arg(long)] + onboarding_run_id: Option, + /// Org ID hint for feature-flag evaluation + #[arg(long)] + org_id: Option, + }, +} + +#[derive(Subcommand)] +enum OnboardingSessionsActions { + /// Record an onboarding session outcome + Create { + /// Session (onboarding run) ID + #[arg(long)] + session_id: String, + /// Skill ID touched in this session (repeat for multiple) + #[arg(long = "skill-id")] + skill_ids: Vec, + /// Short summary of the session outcome + #[arg(long)] + summary: String, + /// Session status: in_progress, completed, failed, or abandoned + #[arg(long)] + status: String, + /// Org ID to attribute the session to + #[arg(long)] + org_id: Option, + }, +} + // ---- On-Call ---- #[derive(Subcommand)] enum OnCallActions { @@ -13703,6 +13794,49 @@ async fn main_inner() -> anyhow::Result<()> { } } // --- On-Call --- + // --- Onboarding --- + // No validate_auth(): these routes are public and work without credentials. + Commands::Onboarding { action } => match action { + OnboardingActions::Skills { action } => match action { + OnboardingSkillsActions::List { org_id } => { + commands::onboarding::skills_list(&cfg, org_id).await?; + } + OnboardingSkillsActions::Get { + skill_id, + intent, + onboarding_run_id, + org_id, + } => { + commands::onboarding::skills_get( + &cfg, + &skill_id, + intent.as_deref(), + onboarding_run_id.as_deref(), + org_id, + ) + .await?; + } + }, + OnboardingActions::Sessions { action } => match action { + OnboardingSessionsActions::Create { + session_id, + skill_ids, + summary, + status, + org_id, + } => { + commands::onboarding::sessions_create( + &cfg, + &session_id, + &skill_ids, + &summary, + &status, + org_id, + ) + .await?; + } + }, + }, Commands::OnCall { action } => { cfg.validate_auth()?; match action { From 6784b62eab69299da7e1e541dc2ad89673c7644e Mon Sep 17 00:00:00 2001 From: Teddy Kahwaji Date: Mon, 13 Jul 2026 15:17:12 -0400 Subject: [PATCH 2/2] fix(onboarding): require a skill_id for terminal session statuses clap derives Vec as optional, so `sessions create` would post an empty skill_ids and defer the error to the backend. Mirror the API's SessionStatus.IsTerminal() rule client-side: require at least one --skill-id for completed/failed/abandoned, still allow empty for in_progress. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/onboarding.rs | 49 ++++++++++++++++++++++++++++++++++++++ src/main.rs | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/commands/onboarding.rs b/src/commands/onboarding.rs index 402a98d0..34af17a6 100644 --- a/src/commands/onboarding.rs +++ b/src/commands/onboarding.rs @@ -9,6 +9,8 @@ const SKILLS_PATH: &str = "/api/v2/onboarding/skills"; const SESSIONS_PATH: &str = "/api/v2/onboarding/sessions"; const VALID_STATUSES: &[&str] = &["in_progress", "completed", "failed", "abandoned"]; +// Terminal statuses require at least one skill_id; the backend allows in_progress to be empty. +const TERMINAL_STATUSES: &[&str] = &["completed", "failed", "abandoned"]; const VALID_INTENTS: &[&str] = &["explore", "install", "reference"]; /// List the available Datadog onboarding skills (`GET /api/v2/onboarding/skills`). @@ -73,6 +75,10 @@ pub async fn sessions_create( ); } + if TERMINAL_STATUSES.contains(&status) && skill_ids.is_empty() { + anyhow::bail!("at least one --skill-id is required when status is '{status}'"); + } + let mut attributes = serde_json::json!({ "skill_ids": skill_ids, "summary": summary, @@ -301,4 +307,47 @@ mod tests { assert!(err.contains("invalid status"), "got: {err}"); cleanup_env(); } + + #[tokio::test] + async fn sessions_create_terminal_requires_skill_id() { + let _lock = lock_env().await; + let server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let result = + super::sessions_create(&cfg, "run-123", &[], "summary", "completed", None).await; + let err = result.unwrap_err().to_string(); + assert!(err.contains("at least one --skill-id"), "got: {err}"); + cleanup_env(); + } + + #[tokio::test] + async fn sessions_create_in_progress_allows_empty_skill_ids() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let mock = server + .mock("POST", "/api/v2/onboarding/sessions") + .match_body(mockito::Matcher::PartialJson(serde_json::json!({ + "data": { + "type": "onboarding_session", + "id": "run-123", + "attributes": { "status": "in_progress", "skill_ids": [] }, + }, + }))) + .with_status(201) + .with_header("content-type", "application/vnd.api+json") + .with_body(r#"{"data":{"id":"run-123"}}"#) + .create_async() + .await; + let result = + super::sessions_create(&cfg, "run-123", &[], "session started", "in_progress", None) + .await; + assert!( + result.is_ok(), + "in_progress create failed: {:?}", + result.err() + ); + mock.assert_async().await; + cleanup_env(); + } } diff --git a/src/main.rs b/src/main.rs index df0f9e91..a6b3e005 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6537,7 +6537,7 @@ enum OnboardingSessionsActions { /// Session (onboarding run) ID #[arg(long)] session_id: String, - /// Skill ID touched in this session (repeat for multiple) + /// Skill ID touched in this session (repeat for multiple; required unless status is in_progress) #[arg(long = "skill-id")] skill_ids: Vec, /// Short summary of the session outcome