Skip to content
1 change: 1 addition & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pup skills list

| Skill | Description |
|-------|-------------|
| **dd-onboarding** | Agent install, APM, logs, integrations — AI-driven setup via live skill API |
| **dd-pup** | Primary CLI - all pup commands, auth, site config |
| **dd-monitors** | Create, manage, mute monitors and alerts |
| **dd-logs** | Search logs, pipelines, archives |
Expand Down
47 changes: 47 additions & 0 deletions skills/dd-onboarding/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
name: dd-onboarding
description: Datadog Agentic Onboarding. Guides installation and setup of the Datadog Agent, APM, logs, integrations, and cloud environments via an AI-driven workflow.
metadata:
version: "1.0.0"
author: datadog-labs
repository: https://github.com/DataDog/dd-source
tags: datadog,onboarding,setup,install,apm,agent,kubernetes,docker,lambda,ecs
alwaysApply: "false"
---

# Datadog Agentic Onboarding

For any Datadog setup, installation, configuration, or troubleshooting task, fetch the onboarding
orchestrator first and follow its instructions:

```bash
curl -s -X GET "https://api.${DD_SITE:-datadoghq.com}/api/v2/onboarding/skills/orchestrator" \
-H "DD-API-KEY: ${DD_API_KEY}" \
-H "DD-APPLICATION-KEY: ${DD_APP_KEY}" \
| jq -r '.data.attributes.content'
```

The orchestrator will guide credential validation, environment discovery, skill selection, and
step-by-step setup. Follow it exactly.

**If this curl returns an error or empty output — STOP.** Do not proceed with any Datadog setup,
troubleshooting, or configuration. Do not fall back to training knowledge. Fix the fetch first:
- `{"errors":["Forbidden"]}` → the API key lacks permission for the onboarding API, or the service
is not available at this site. Ask the user to confirm `DD_SITE` and verify the key has `apm_read`
scope, or provide the correct endpoint.
- Empty output → pipe failed. Run the curl without `| jq` to see the raw response.
- Any other error → show the raw response to the user and wait for guidance.

There is no fallback. The orchestrator content must be loaded before any action is taken.

## Prerequisites

`DD_API_KEY`, `DD_APP_KEY`, and `DD_SITE` must be set before fetching the orchestrator. If any are
missing, ask the user to provide them. Default `DD_SITE` to `datadoghq.com` if unset.

## When this skill applies

- Installing the Datadog Agent on any platform (Kubernetes, Linux, Docker, ECS, Lambda, Azure, GCP)
- Setting up APM, logs, RUM, or cloud integrations
- Troubleshooting an existing Datadog Agent or APM installation
- Any question that starts with "how do I monitor…", "set up Datadog on…", or "install the agent on…"
215 changes: 214 additions & 1 deletion src/commands/skills.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
use anyhow::{bail, Result};

use crate::config::Config;
use crate::formatter;
use crate::raw_client;
use crate::skills;
use std::path::Path;

/// Resolve the platform list from CLI input, validating each entry.
///
Expand Down Expand Up @@ -300,11 +304,135 @@ pub fn path(platform: Option<String>, project: bool) -> Result<()> {
Ok(())
}

pub async fn catalog_list(cfg: &Config, tags: Vec<String>) -> Result<()> {
let query = tags
.iter()
.map(|t| format!("tags={t}"))
.collect::<Vec<_>>()
.join("&");
let path = if query.is_empty() {
"/api/v2/skills".to_string()
} else {
format!("/api/v2/skills?{query}")
};
let data = raw_client::raw_get(cfg, &path, &[]).await?;
formatter::output(cfg, &data)
}

const VALID_CATALOG_INTENTS: &[&str] = &["explore", "install", "reference"];

pub async fn catalog_get(
cfg: &Config,
name: String,
intent: Option<String>,
onboarding_run_id: Option<String>,
org_id: Option<i64>,
) -> Result<()> {
if let Some(intent) = &intent {
if !VALID_CATALOG_INTENTS.contains(&intent.as_str()) {
bail!(
"invalid intent '{intent}'; expected one of: {}",
VALID_CATALOG_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.as_str()));
}
if let Some(onboarding_run_id) = &onboarding_run_id {
query.push(("onboarding_run_id", onboarding_run_id.as_str()));
}
if let Some(org) = &org {
query.push(("org_id", org.as_str()));
}

let path = format!("/api/v2/skills/{name}");
let data = raw_client::raw_get(cfg, &path, &query).await?;
formatter::output(cfg, &data)
}

pub async fn catalog_publish(
cfg: &Config,
file: String,
name: String,
description: Option<String>,
tags: Vec<String>,
) -> Result<()> {
let content = std::fs::read_to_string(Path::new(&file))
.map_err(|e| anyhow::anyhow!("failed to read {file}: {e}"))?;
let desc = description.unwrap_or_default();
let body = serde_json::json!({
"data": {
"type": "registry_skill",
"attributes": {
"name": name,
"description": desc,
"content": content,
"tags": tags,
}
}
});
let data = raw_client::raw_post(cfg, "/api/v2/skills", body).await?;
formatter::output(cfg, &data)
}

pub async fn catalog_update(
cfg: &Config,
file: String,
name: String,
description: Option<String>,
tags: Vec<String>,
) -> Result<()> {
let content = std::fs::read_to_string(Path::new(&file))
.map_err(|e| anyhow::anyhow!("failed to read {file}: {e}"))?;
let mut attrs = serde_json::json!({
"content": content,
"tags": tags,
});
if let Some(desc) = description {
attrs["description"] = serde_json::json!(desc);
}
let body = serde_json::json!({
"data": {
"type": "registry_skill",
"attributes": attrs,
}
});
let path = format!("/api/v2/skills/{}", name.replace('/', "%2F"));
let data = raw_client::raw_put(cfg, &path, body).await?;
formatter::output(cfg, &data)
}

pub async fn session_record(
cfg: &Config,
session_id: String,
skill_ids: Vec<String>,
summary: String,
status: String,
) -> Result<()> {
let body = serde_json::json!({
"data": {
"type": "onboarding_session",
"id": session_id,
"attributes": {
"skill_ids": skill_ids,
"summary": summary,
"status": status,
}
}
});
let data = raw_client::raw_post(cfg, "/api/v2/onboarding/sessions", body).await?;
formatter::output(cfg, &data)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::test_support::TempDir;
use crate::test_support::*;

fn base_cfg() -> Config {
Config {
Expand Down Expand Up @@ -527,4 +655,89 @@ mod tests {
.to_string();
assert!(err.contains("skill not found"), "got: {err}");
}

#[tokio::test]
async fn catalog_get_ok_without_query_params() {
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/skills/dd-apm")
.match_query(mockito::Matcher::Missing)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data":{"id":"dd-apm"}}"#)
.create_async()
.await;
let result = catalog_get(&cfg, "dd-apm".to_string(), None, None, None).await;
assert!(result.is_ok(), "catalog get failed: {:?}", result.err());
mock.assert_async().await;
cleanup_env();
}

#[tokio::test]
async fn catalog_get_forwards_intent_onboarding_run_id_and_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/skills/dd-apm")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("intent".into(), "install".into()),
mockito::Matcher::UrlEncoded("onboarding_run_id".into(), "run-123".into()),
mockito::Matcher::UrlEncoded("org_id".into(), "42".into()),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data":{"id":"dd-apm"}}"#)
.create_async()
.await;
let result = catalog_get(
&cfg,
"dd-apm".to_string(),
Some("install".to_string()),
Some("run-123".to_string()),
Some(42),
)
.await;
assert!(result.is_ok(), "catalog get failed: {:?}", result.err());
mock.assert_async().await;
cleanup_env();
}

#[tokio::test]
async fn catalog_get_rejects_invalid_intent() {
let _lock = lock_env().await;
let server = mockito::Server::new_async().await;
let cfg = test_config(&server.url());
let result = catalog_get(
&cfg,
"dd-apm".to_string(),
Some("bogus".to_string()),
None,
None,
)
.await;
let err = result.unwrap_err().to_string();
assert!(err.contains("invalid intent"), "got: {err}");
cleanup_env();
}

#[tokio::test]
async fn catalog_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/json")
.with_body(r#"{"errors":[{"detail":"Skill not found: nope"}]}"#)
.create_async()
.await;
let result = catalog_get(&cfg, "nope".to_string(), None, None, None).await;
let err = result.unwrap_err().to_string();
assert!(err.contains("404"), "expected 404 in error, got: {err}");
cleanup_env();
}
}
Loading
Loading