use std::io::Write;
use sqlx::Database;
use crate::sql::Pool;
use crate::tenancy::error::TenancyError;
use crate::tenancy::manage::args::{next_value, reject_leading_flag};
use crate::tenancy::pools::TenantPools;
use super::users::scoped_tenant_pool;
const CREATE_HELP: &str = "create-agent <slug> <name>";
const ROTATE_HELP: &str = "rotate-agent-secret <slug> <name>";
const LIST_HELP: &str = "list-agents <slug>";
const CREATE_SKILL_HELP: &str =
"create-skill <slug> <codename> [--name <s>] [--description <s>] [--tools t1,t2] [--instructions <s>]";
const GRANT_HELP: &str = "grant-skill <slug> <agent> <skill>";
const REVOKE_HELP: &str = "revoke-skill <slug> <agent> <skill>";
const LIST_SKILLS_HELP: &str = "list-skills <slug>";
const CREATE_USER_KEY_HELP: &str =
"create-user-key <slug> <username> [--label <l>] [--skill <codename>]… \
(repeat --skill to scope the key to a skillset; omit for a full key)";
const LIST_USER_KEYS_HELP: &str = "list-user-keys <slug> <username>";
const REVOKE_USER_KEY_HELP: &str = "revoke-user-key <slug> <username> <key_id>";
const MAP_SKILL_PERM_HELP: &str = "map-skill-permission <slug> <skill> <permission>";
const UNMAP_SKILL_PERM_HELP: &str = "unmap-skill-permission <slug> <skill> <permission>";
pub(super) async fn create_agent_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
reject_leading_flag(args, "create-agent", "slug", CREATE_HELP)?;
let mut iter = args.iter();
let slug = iter
.next()
.cloned()
.ok_or_else(|| TenancyError::Validation(CREATE_HELP.into()))?;
let name = iter
.next()
.cloned()
.ok_or_else(|| TenancyError::Validation(CREATE_HELP.into()))?;
if let Some(extra) = iter.next() {
return Err(TenancyError::Validation(format!(
"create-agent: unexpected argument `{extra}`"
)));
}
let scoped = scoped_tenant_pool(pools, registry_url, &slug).await?;
let issued = crate::tenancy::create_agent_pool(&scoped, &name)
.await
.map_err(|e| TenancyError::Validation(e.to_string()))?;
let id = issued.agent.id.get().copied().unwrap_or_default();
writeln!(w, "created agent `{name}` (id {id}) in tenant `{slug}`")?;
writeln!(w, " secret: {}", issued.token)?;
writeln!(w, " store this safely — it won't be shown again")?;
Ok(())
}
pub(super) async fn rotate_agent_secret_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
reject_leading_flag(args, "rotate-agent-secret", "slug", ROTATE_HELP)?;
let mut iter = args.iter();
let slug = iter
.next()
.cloned()
.ok_or_else(|| TenancyError::Validation(ROTATE_HELP.into()))?;
let name = iter
.next()
.cloned()
.ok_or_else(|| TenancyError::Validation(ROTATE_HELP.into()))?;
let scoped = scoped_tenant_pool(pools, registry_url, &slug).await?;
let issued = crate::tenancy::rotate_agent_secret_pool(&scoped, &name)
.await
.map_err(|e| TenancyError::Validation(e.to_string()))?;
writeln!(w, "rotated secret for agent `{name}` in tenant `{slug}`")?;
writeln!(w, " new secret: {}", issued.token)?;
writeln!(w, " the previous secret no longer authenticates")?;
Ok(())
}
pub(super) async fn list_agents_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
reject_leading_flag(args, "list-agents", "slug", LIST_HELP)?;
let slug = args
.first()
.cloned()
.ok_or_else(|| TenancyError::Validation(LIST_HELP.into()))?;
let scoped = scoped_tenant_pool(pools, registry_url, &slug).await?;
let agents = crate::tenancy::list_agents_pool(&scoped)
.await
.map_err(|e| TenancyError::Validation(e.to_string()))?;
if agents.is_empty() {
writeln!(w, "no agents in tenant `{slug}`")?;
return Ok(());
}
writeln!(w, "agents in tenant `{slug}`:")?;
for a in &agents {
let id = a.id.get().copied().unwrap_or_default();
let status = if a.active { "active" } else { "disabled" };
writeln!(
w,
" {id:>4} {} ({status}, prefix {})",
a.name, a.secret_prefix
)?;
}
Ok(())
}
pub(super) async fn create_skill_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
reject_leading_flag(args, "create-skill", "slug", CREATE_SKILL_HELP)?;
let mut iter = args.iter();
let slug = iter
.next()
.cloned()
.ok_or_else(|| TenancyError::Validation(CREATE_SKILL_HELP.into()))?;
let codename = iter
.next()
.cloned()
.ok_or_else(|| TenancyError::Validation(CREATE_SKILL_HELP.into()))?;
let mut name = String::new();
let mut description = String::new();
let mut instructions = String::new();
let mut tools: Vec<String> = Vec::new();
while let Some(flag) = iter.next() {
match flag.as_str() {
"--name" => name = next_value(&mut iter, "--name")?,
"--description" => description = next_value(&mut iter, "--description")?,
"--instructions" => instructions = next_value(&mut iter, "--instructions")?,
"--tools" => {
tools = next_value(&mut iter, "--tools")?
.split(',')
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.collect();
}
other => {
return Err(TenancyError::Validation(format!(
"create-skill: unknown argument `{other}`"
)));
}
}
}
let scoped = scoped_tenant_pool(pools, registry_url, &slug).await?;
let skill = crate::tenancy::create_skill_pool(
&scoped,
&codename,
&name,
&description,
&instructions,
&tools,
)
.await
.map_err(|e| TenancyError::Validation(e.to_string()))?;
let id = skill.id.get().copied().unwrap_or_default();
writeln!(
w,
"created skill `{codename}` (id {id}) in tenant `{slug}` with {} tool(s)",
tools.len()
)?;
Ok(())
}
pub(super) async fn grant_skill_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
let (slug, agent, skill) = three_positionals(args, "grant-skill", GRANT_HELP)?;
let scoped = scoped_tenant_pool(pools, registry_url, &slug).await?;
crate::tenancy::grant_skill_pool(&scoped, &slug, &agent, &skill)
.await
.map_err(|e| TenancyError::Validation(e.to_string()))?;
writeln!(
w,
"granted skill `{skill}` to agent `{agent}` in tenant `{slug}`"
)?;
Ok(())
}
pub(super) async fn revoke_skill_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
let (slug, agent, skill) = three_positionals(args, "revoke-skill", REVOKE_HELP)?;
let scoped = scoped_tenant_pool(pools, registry_url, &slug).await?;
crate::tenancy::revoke_skill_pool(&scoped, &slug, &agent, &skill)
.await
.map_err(|e| TenancyError::Validation(e.to_string()))?;
writeln!(
w,
"revoked skill `{skill}` from agent `{agent}` in tenant `{slug}`"
)?;
Ok(())
}
pub(super) async fn list_skills_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
reject_leading_flag(args, "list-skills", "slug", LIST_SKILLS_HELP)?;
let slug = args
.first()
.cloned()
.ok_or_else(|| TenancyError::Validation(LIST_SKILLS_HELP.into()))?;
let scoped = scoped_tenant_pool(pools, registry_url, &slug).await?;
let skills = crate::tenancy::list_skills_pool(&scoped)
.await
.map_err(|e| TenancyError::Validation(e.to_string()))?;
if skills.is_empty() {
writeln!(w, "no skills in tenant `{slug}`")?;
return Ok(());
}
writeln!(w, "skills in tenant `{slug}`:")?;
for s in &skills {
writeln!(w, " {} {}", s.codename, s.name)?;
}
Ok(())
}
pub(super) async fn create_user_key_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
reject_leading_flag(args, "create-user-key", "slug", CREATE_USER_KEY_HELP)?;
let slug = args
.first()
.cloned()
.ok_or_else(|| TenancyError::Validation(CREATE_USER_KEY_HELP.into()))?;
let username = args
.get(1)
.cloned()
.ok_or_else(|| TenancyError::Validation(CREATE_USER_KEY_HELP.into()))?;
let mut label: Option<String> = None;
let mut skills: Vec<String> = Vec::new();
let mut i = 2;
while i < args.len() {
match args[i].as_str() {
"--label" => {
i += 1;
label = Some(args.get(i).cloned().ok_or_else(|| {
TenancyError::Validation("create-user-key: --label needs a value".into())
})?);
}
"--skill" => {
i += 1;
skills.push(args.get(i).cloned().ok_or_else(|| {
TenancyError::Validation("create-user-key: --skill needs a codename".into())
})?);
}
other => {
return Err(TenancyError::Validation(format!(
"create-user-key: unexpected argument `{other}` ({CREATE_USER_KEY_HELP})"
)));
}
}
i += 1;
}
let label = label.unwrap_or_else(|| username.clone());
let scoped = scoped_tenant_pool(pools, registry_url, &slug).await?;
let uid = resolve_user_id(&scoped, &username).await?;
let issued = crate::tenancy::create_user_key_pool(&scoped, uid, &label, &skills)
.await
.map_err(|e| TenancyError::Validation(e.to_string()))?;
let id = issued.agent.id.get().copied().unwrap_or_default();
let scope = if skills.is_empty() {
"full (owner's permissions)".to_owned()
} else {
format!("skills: {}", skills.join(", "))
};
writeln!(
w,
"created key #{id} for user `{username}` in tenant `{slug}` (label `{label}`, scope {scope})"
)?;
writeln!(w, " token: {}", issued.token)?;
writeln!(w, " store this safely — it won't be shown again")?;
Ok(())
}
pub(super) async fn list_user_keys_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
reject_leading_flag(args, "list-user-keys", "slug", LIST_USER_KEYS_HELP)?;
let mut iter = args.iter();
let slug = iter
.next()
.cloned()
.ok_or_else(|| TenancyError::Validation(LIST_USER_KEYS_HELP.into()))?;
let username = iter
.next()
.cloned()
.ok_or_else(|| TenancyError::Validation(LIST_USER_KEYS_HELP.into()))?;
let scoped = scoped_tenant_pool(pools, registry_url, &slug).await?;
let uid = resolve_user_id(&scoped, &username).await?;
let keys = crate::tenancy::list_user_keys_pool(&scoped, uid)
.await
.map_err(|e| TenancyError::Validation(e.to_string()))?;
if keys.is_empty() {
writeln!(w, "no keys for user `{username}`")?;
return Ok(());
}
writeln!(w, "keys for user `{username}` in tenant `{slug}`:")?;
for k in &keys {
let id = k.id.get().copied().unwrap_or_default();
let label = k
.data
.get("label")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let created = k
.created_at
.get()
.map(chrono::DateTime::to_rfc3339)
.unwrap_or_default();
writeln!(w, " #{id} {label} {created}")?;
}
Ok(())
}
pub(super) async fn revoke_user_key_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
reject_leading_flag(args, "revoke-user-key", "slug", REVOKE_USER_KEY_HELP)?;
let mut iter = args.iter();
let slug = iter
.next()
.cloned()
.ok_or_else(|| TenancyError::Validation(REVOKE_USER_KEY_HELP.into()))?;
let username = iter
.next()
.cloned()
.ok_or_else(|| TenancyError::Validation(REVOKE_USER_KEY_HELP.into()))?;
let key_id: i64 = iter
.next()
.ok_or_else(|| TenancyError::Validation(REVOKE_USER_KEY_HELP.into()))?
.parse()
.map_err(|_| {
TenancyError::Validation("revoke-user-key: <key_id> must be an integer".into())
})?;
let scoped = scoped_tenant_pool(pools, registry_url, &slug).await?;
let uid = resolve_user_id(&scoped, &username).await?;
crate::tenancy::revoke_user_key_pool(&scoped, uid, key_id)
.await
.map_err(|e| TenancyError::Validation(e.to_string()))?;
writeln!(w, "revoked key #{key_id} for user `{username}`")?;
Ok(())
}
pub(super) async fn map_skill_permission_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
let (slug, skill, permission) =
three_positionals(args, "map-skill-permission", MAP_SKILL_PERM_HELP)?;
let scoped = scoped_tenant_pool(pools, registry_url, &slug).await?;
crate::tenancy::map_skill_to_permission_pool(&scoped, &skill, &permission)
.await
.map_err(|e| TenancyError::Validation(e.to_string()))?;
writeln!(
w,
"mapped skill `{skill}` → permission `{permission}` in tenant `{slug}`"
)?;
Ok(())
}
pub(super) async fn unmap_skill_permission_cmd<W: Write + Send, DB: Database>(
pools: &TenantPools<DB>,
registry_url: &str,
args: &[String],
w: &mut W,
) -> Result<(), TenancyError>
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
let (slug, skill, permission) =
three_positionals(args, "unmap-skill-permission", UNMAP_SKILL_PERM_HELP)?;
let scoped = scoped_tenant_pool(pools, registry_url, &slug).await?;
crate::tenancy::unmap_skill_from_permission_pool(&scoped, &skill, &permission)
.await
.map_err(|e| TenancyError::Validation(e.to_string()))?;
writeln!(w, "unmapped skill `{skill}` ✗ permission `{permission}`")?;
Ok(())
}
async fn resolve_user_id(pool: &Pool, username: &str) -> Result<i64, TenancyError> {
use crate::sql::FetcherPool as _;
let user = crate::tenancy::User::objects()
.filter("username", username.to_owned())
.limit(1)
.fetch(pool)
.await
.map_err(|e| TenancyError::Validation(e.to_string()))?
.into_iter()
.next()
.ok_or_else(|| TenancyError::Validation(format!("unknown user `{username}`")))?;
Ok(user.id.get().copied().unwrap_or_default())
}
fn three_positionals(
args: &[String],
verb: &str,
help: &str,
) -> Result<(String, String, String), TenancyError> {
reject_leading_flag(args, verb, "slug", help)?;
match args {
[slug, a, b] => Ok((slug.clone(), a.clone(), b.clone())),
_ => Err(TenancyError::Validation(help.into())),
}
}