use super::Invocation;
use super::redteam::{page_of, page_value, read, value, walk_all, walk_deadline};
use crate::error::CliError;
use crate::json::OValue;
use crate::output::Format;
use crate::redteam::ListParams;
use crate::redteam::secrets::{self, Shape};
use crate::redteam::views;
use scm::airs::red_team::AdapterListOptions;
pub async fn list(
invocation: &mut Invocation,
params: &ListParams,
search: Option<String>,
format: Option<Format>,
) -> Result<(), CliError> {
let mut options = AdapterListOptions::default();
options.search = search;
let (rows, total, format) = if params.all {
let deadline = walk_deadline()?;
let connected = invocation.connect_red_team(Some(&deadline)).await?;
let format = connected.format(format);
let adapters = connected.client.adapters();
let rows = walk_all(
invocation,
params.limit.unwrap_or(50),
params.max,
true,
|skip, limit| {
let mut page = options.clone();
page.skip = Some(skip);
page.limit = Some(limit);
let adapters = adapters.clone();
let deadline = deadline.clone();
async move {
let answer = adapters.list(&page, Some(&deadline)).await?;
Ok(page_of(&page_value(&answer)?))
}
},
)
.await?;
(rows, None, format)
} else {
let connected = invocation.connect_red_team(None).await?;
let format = connected.format(format);
options.skip = params.offset;
options.limit = params.limit;
let answer = read(invocation, connected.client.adapters().list(&options, None)).await?;
let answer = value(&answer)?;
let total = answer
.get("pagination")
.and_then(|pagination| pagination.get("total_items"))
.filter(|total| !matches!(total, OValue::Null))
.cloned();
(page_of(&answer).rows, total, format)
};
let mut rows: Vec<OValue> = rows.iter().map(views::adapter_row).collect();
for row in &mut rows {
views::withhold(row, &invocation.secrets);
}
views::list(
&mut invocation.output,
format,
&rows,
&views::ADAPTER_COLUMNS,
"adapters",
|out, rows| views::adapter_list_layout(out, rows, total.as_ref()),
)
}
pub async fn get(
invocation: &mut Invocation,
uuid: &str,
format: Option<Format>,
) -> Result<(), CliError> {
let connected = invocation.connect_red_team(None).await?;
let format = connected.format(format);
let answer = read(invocation, connected.client.adapters().get(uuid, None)).await?;
let mut doc = views::adapter_detail(&value(&answer)?);
secrets::redact(&mut doc, Shape::Adapter, &invocation.secrets);
views::withhold(&mut doc, &invocation.secrets);
views::detail(&mut invocation.output, format, &doc, |out| {
views::adapter_layout(out, &doc);
})
}
use super::redteam_write::{self as rw, Contexts};
use crate::redteam::args::{self, Script, Variables};
use crate::writes::Change;
use scm::airs::red_team::{AdapterOperationOptions, models as rtm};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AdapterPlan {
pub kind: AdapterKind,
pub name: Option<String>,
pub prompt: String,
pub script: Script,
pub description: Option<String>,
pub channel: Option<String>,
pub variables: Variables,
pub draft: bool,
pub format: Format,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AdapterKind {
Create,
Update(String),
Validate(Option<String>),
}
fn inputs(
invocation: &Invocation,
plan: &AdapterPlan,
) -> Result<(Option<String>, Option<serde_json::Value>), CliError> {
let script = match &plan.script {
Script::None => None,
Script::Base64(b64) => Some(b64.clone()),
Script::File(path) => Some(args::base64(&crate::files::read_bytes(
path,
crate::files::SCRIPT_LIMIT,
)?)),
};
let variables = match &plan.variables {
Variables::None => None,
Variables::Inline(value) => Some(value.clone()),
Variables::File(path) => {
let text = crate::files::read_text(path, crate::files::CONFIG_LIMIT)?;
Some(args::variable_list(&text, "--variables-file").map_err(CliError::operational)?)
}
};
if let Some(serde_json::Value::Array(items)) = &variables {
for item in items {
if item.get("type").and_then(serde_json::Value::as_str) == Some("SECRET")
&& let Some(value) = item.get("value").and_then(serde_json::Value::as_str)
{
invocation.secrets.add(value);
}
}
}
Ok((script, variables))
}
async fn channel_online(
invocation: &Invocation,
client: &scm::airs::red_team::RedTeamClient,
channel: &str,
contexts: &Contexts,
) -> Result<(), CliError> {
match client.network_broker().get_channel(channel, None).await {
Ok(found) => {
let status = value(&found)?
.get("status")
.and_then(OValue::as_str)
.unwrap_or_default()
.to_owned();
if !status.is_empty() && status != "ONLINE" {
return Err(CliError::operational(format!(
"network broker channel {channel} is {} — adapter validation requires an ONLINE channel (network broker v1.4.0+). Check 'airs-cli-rs redteam network-broker channels list'.",
crate::output::terminal_safe(&status)
)));
}
Ok(())
}
Err(error) if error.kind() == scm::ErrorKind::Cancelled => Err(contexts
.before(invocation.management_error(&error, crate::clients::SINGLE_REQUEST_BUDGET))),
Err(_) => Ok(()),
}
}
fn register_stored(invocation: &Invocation, current: &OValue) {
let mut copy = current.clone();
secrets::redact(&mut copy, Shape::Adapter, &invocation.secrets);
}
fn render_detail(
invocation: &mut Invocation,
format: Format,
raw: &OValue,
) -> Result<(), CliError> {
let mut doc = views::adapter_detail(raw);
secrets::redact(&mut doc, Shape::Adapter, &invocation.secrets);
views::withhold(&mut doc, &invocation.secrets);
views::detail(&mut invocation.output, format, &doc, |out| {
views::adapter_layout(out, &doc);
})
}
pub async fn write(invocation: &mut Invocation, plan: &AdapterPlan) -> Result<(), CliError> {
let (script, variables) = inputs(invocation, plan)?;
let contexts = Contexts::plain();
let create = match &plan.kind {
AdapterKind::Create => {
let mut body = serde_json::json!({
"name": plan.name.clone().unwrap_or_default(),
"script_b64": script.clone().unwrap_or_default(),
"prompt": plan.prompt,
});
if let Some(description) = &plan.description {
body["description"] = serde_json::json!(description);
}
if let Some(channel) = &plan.channel {
body["network_broker_channel_uuid"] = serde_json::json!(channel);
}
if let Some(variables) = &variables {
body["variables"] = variables.clone();
}
Some(rw::body(
body,
"POST",
"/v1/adapters",
rtm::AdapterCreateRequest::parse,
)?)
}
_ => None,
};
let prepared = rw::connect(invocation, &contexts).await?;
let client = prepared.client().clone();
let adapters = client.adapters();
let check_channel = match &plan.kind {
AdapterKind::Validate(_) => plan.channel.as_deref(),
_ if plan.draft => None,
_ => plan.channel.as_deref(),
};
if let Some(channel) = check_channel {
channel_online(invocation, &client, channel, &contexts).await?;
}
let mut operation = AdapterOperationOptions::default();
operation.validate = Some(!plan.draft);
match &plan.kind {
AdapterKind::Create => {
let Some(body) = create else {
return Ok(());
};
let answer = rw::send(
invocation,
Change::Words("adapter", "created"),
None,
&contexts,
async || adapters.create(&body, Some(&operation), None).await,
)
.await?;
invocation.committed = true;
let answer = value(&answer)?;
invocation
.output
.success(&format!("Adapter created: {}", rw::field(&answer, "uuid")));
render_detail(invocation, plan.format, &answer)
}
AdapterKind::Update(uuid) => {
let current = read(invocation, adapters.get(uuid, None))
.await
.map_err(|error| contexts.before(error))?;
let current = value(¤t)?;
register_stored(invocation, ¤t);
let string = |key: &str| current.get(key).and_then(OValue::as_str).map(str::to_owned);
let mut body = serde_json::json!({
"name": plan.name.clone().or_else(|| string("name")).unwrap_or_default(),
"script_b64": script.clone().or_else(|| string("script_b64")).unwrap_or_default(),
"prompt": plan.prompt,
"variables": variables.clone().unwrap_or_else(|| args::preserve_variables(¤t)),
});
if let Some(description) = plan.description.clone().or_else(|| string("description")) {
body["description"] = serde_json::json!(description);
}
if let Some(channel) = plan
.channel
.clone()
.or_else(|| string("network_broker_channel_uuid"))
{
body["network_broker_channel_uuid"] = serde_json::json!(channel);
}
let body = rw::body(
body,
"PUT",
&format!("/v1/adapters/{uuid}"),
rtm::AdapterUpdateRequest::parse,
)?;
let answer = rw::send(
invocation,
Change::Words("adapter", "updated"),
None,
&contexts,
async || adapters.update(uuid, &body, Some(&operation), None).await,
)
.await?;
invocation.committed = true;
let answer = value(&answer)?;
invocation
.output
.success(&format!("Adapter updated: {}", rw::field(&answer, "uuid")));
render_detail(invocation, plan.format, &answer)
}
AdapterKind::Validate(adapter) => {
let variables = match (&variables, adapter) {
(None, Some(adapter)) => {
let current = read(invocation, adapters.get(adapter, None))
.await
.map_err(|error| contexts.before(error))?;
let current = value(¤t)?;
register_stored(invocation, ¤t);
Some(args::preserve_variables(¤t))
}
(variables, _) => variables.clone(),
};
let mut body = serde_json::json!({
"script_b64": script.clone().unwrap_or_default(),
"network_broker_channel_uuid": plan.channel.clone().unwrap_or_default(),
"prompt": plan.prompt,
});
if let Some(variables) = variables {
body["variables"] = variables;
}
if let Some(adapter) = adapter {
body["adapter_uuid"] = serde_json::json!(adapter);
}
let body = rw::body(
body,
"POST",
"/v1/adapters/validate",
rtm::AdapterValidateRequest::parse,
)?;
let answer = rw::send(
invocation,
Change::Words("adapter script", "run"),
None,
&contexts,
async || adapters.validate(&body, None).await,
)
.await?;
let mut doc = views::adapter_validation(&value(&answer)?);
views::withhold_outputs(&mut doc, &invocation.secrets);
let validated = matches!(doc.get("validated"), Some(OValue::Bool(true)));
views::detail(&mut invocation.output, plan.format, &doc, |out| {
views::adapter_validation_layout(out, &doc);
})?;
if validated {
invocation.output.success("Adapter script validated");
return Ok(());
}
let _ = invocation.output.commit();
let line = invocation.output.paint_err(
crate::render::style::Style::Red,
"✗ Adapter script validation FAILED",
);
invocation.output.write_stderr(&format!(" {line}\n"));
Err(CliError::Reported(crate::error::EXIT_OPERATIONAL))
}
}
}
pub async fn delete(invocation: &mut Invocation, uuid: &str, force: bool) -> Result<(), CliError> {
let contexts = Contexts::plain();
let prepared = invocation
.red_team_unauthenticated()
.map_err(|error| contexts.before(error))?;
if !force && !rw::confirm(invocation, &format!("Delete adapter {uuid}?"))? {
return Ok(());
}
rw::authenticate(&prepared, &contexts).await?;
let adapters = prepared.client().adapters();
rw::send(
invocation,
Change::Words("adapter", "deleted"),
None,
&contexts,
async || adapters.delete(uuid, None).await,
)
.await?;
invocation.committed = true;
invocation
.output
.success(&format!("Adapter {uuid} deleted."));
Ok(())
}