Skip to main content

ai_crew_sync/
admin.rs

1//! Operator-facing commands. These bypass MCP entirely and talk to Postgres
2//! directly, so they are the only way to mint credentials.
3
4use anyhow::{Context, bail};
5use sqlx::PgPool;
6use uuid::Uuid;
7
8use crate::auth::{generate_token, hash_token, token_prefix};
9
10async fn team_id(pool: &PgPool, slug: &str) -> anyhow::Result<Uuid> {
11    let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM teams WHERE slug = $1")
12        .bind(slug)
13        .fetch_optional(pool)
14        .await?;
15    row.map(|r| r.0)
16        .with_context(|| format!("no team with slug '{slug}'"))
17}
18
19pub async fn team_create(pool: &PgPool, slug: &str, name: Option<String>) -> anyhow::Result<()> {
20    let slug = slug.trim().to_lowercase();
21    if slug.is_empty() {
22        bail!("team slug cannot be empty");
23    }
24    let name = name.unwrap_or_else(|| slug.clone());
25    sqlx::query("INSERT INTO teams (slug, name) VALUES ($1, $2) ON CONFLICT (slug) DO NOTHING")
26        .bind(&slug)
27        .bind(&name)
28        .execute(pool)
29        .await?;
30    println!("team '{slug}' ready");
31    Ok(())
32}
33
34pub async fn team_list(pool: &PgPool) -> anyhow::Result<()> {
35    let rows: Vec<(String, String, i64)> = sqlx::query_as(
36        r#"
37        SELECT t.slug, t.name, (SELECT count(*) FROM agents a WHERE a.team_id = t.id)
38        FROM teams t ORDER BY t.slug
39        "#,
40    )
41    .fetch_all(pool)
42    .await?;
43    if rows.is_empty() {
44        println!("(no teams yet — create one with `team create --slug <slug>`)");
45    }
46    for (slug, name, agents) in rows {
47        println!("{slug:<20} {name:<30} {agents} agent(s)");
48    }
49    Ok(())
50}
51
52pub async fn agent_add(
53    pool: &PgPool,
54    team: &str,
55    name: &str,
56    display_name: Option<String>,
57    issue_token: bool,
58) -> anyhow::Result<()> {
59    let tid = team_id(pool, team).await?;
60    let name = name.trim().to_lowercase();
61    if name.is_empty() {
62        bail!("agent name cannot be empty");
63    }
64
65    sqlx::query(
66        r#"
67        INSERT INTO agents (team_id, name, display_name)
68        VALUES ($1, $2, $3)
69        ON CONFLICT (team_id, name) DO UPDATE
70            SET display_name = COALESCE(EXCLUDED.display_name, agents.display_name),
71                disabled_at = NULL
72        "#,
73    )
74    .bind(tid)
75    .bind(&name)
76    .bind(display_name)
77    .execute(pool)
78    .await?;
79    println!("agent '{name}' ready in team '{team}'");
80
81    if issue_token {
82        token_issue(pool, team, &name, None).await?;
83    }
84    Ok(())
85}
86
87pub async fn agent_list(pool: &PgPool, team: &str) -> anyhow::Result<()> {
88    let tid = team_id(pool, team).await?;
89    let rows: Vec<(String, Option<String>, bool, i64)> = sqlx::query_as(
90        r#"
91        SELECT a.name,
92               a.display_name,
93               (a.disabled_at IS NOT NULL) AS disabled,
94               (SELECT count(*) FROM api_tokens t
95                 WHERE t.agent_id = a.id AND t.revoked_at IS NULL)
96        FROM agents a WHERE a.team_id = $1 ORDER BY a.name
97        "#,
98    )
99    .bind(tid)
100    .fetch_all(pool)
101    .await?;
102    for (name, display, disabled, tokens) in rows {
103        let flag = if disabled { " [disabled]" } else { "" };
104        println!(
105            "{name:<24} {:<28} {tokens} active token(s){flag}",
106            display.unwrap_or_default()
107        );
108    }
109    Ok(())
110}
111
112pub async fn agent_disable(pool: &PgPool, team: &str, name: &str) -> anyhow::Result<()> {
113    let tid = team_id(pool, team).await?;
114    let res = sqlx::query("UPDATE agents SET disabled_at = now() WHERE team_id = $1 AND name = $2")
115        .bind(tid)
116        .bind(name.trim())
117        .execute(pool)
118        .await?;
119    if res.rows_affected() == 0 {
120        bail!("no agent '{name}' in team '{team}'");
121    }
122    println!("agent '{name}' disabled; its tokens no longer authenticate");
123    Ok(())
124}
125
126pub async fn token_issue(
127    pool: &PgPool,
128    team: &str,
129    agent: &str,
130    label: Option<String>,
131) -> anyhow::Result<()> {
132    let tid = team_id(pool, team).await?;
133    let row: Option<(Uuid,)> =
134        sqlx::query_as("SELECT id FROM agents WHERE team_id = $1 AND name = $2")
135            .bind(tid)
136            .bind(agent.trim())
137            .fetch_optional(pool)
138            .await?;
139    let Some((agent_id,)) = row else {
140        bail!("no agent '{agent}' in team '{team}' — add it with `agent add` first");
141    };
142
143    let raw = generate_token();
144    sqlx::query(
145        "INSERT INTO api_tokens (agent_id, token_hash, prefix, label) VALUES ($1, $2, $3, $4)",
146    )
147    .bind(agent_id)
148    .bind(hash_token(&raw))
149    .bind(token_prefix(&raw))
150    .bind(label)
151    .execute(pool)
152    .await?;
153
154    println!();
155    println!("Token for {agent}@{team} — shown once, store it now:");
156    println!();
157    println!("  {raw}");
158    println!();
159    Ok(())
160}
161
162pub async fn token_list(pool: &PgPool, team: &str) -> anyhow::Result<()> {
163    let tid = team_id(pool, team).await?;
164    let rows: Vec<(
165        Uuid,
166        String,
167        String,
168        Option<String>,
169        Option<chrono::DateTime<chrono::Utc>>,
170        bool,
171    )> = sqlx::query_as(
172        r#"
173        SELECT t.id, a.name, t.prefix, t.label, t.last_used_at,
174               (t.revoked_at IS NOT NULL) AS revoked
175        FROM api_tokens t
176        JOIN agents a ON a.id = t.agent_id
177        WHERE a.team_id = $1
178        ORDER BY a.name, t.created_at
179        "#,
180    )
181    .bind(tid)
182    .fetch_all(pool)
183    .await?;
184    for (id, agent, prefix, label, last_used, revoked) in rows {
185        let used = last_used
186            .map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
187            .unwrap_or_else(|| "never".into());
188        let flag = if revoked { " [revoked]" } else { "" };
189        println!(
190            "{id}  {agent:<20} {prefix}…  last used {used}  {}{flag}",
191            label.unwrap_or_default()
192        );
193    }
194    Ok(())
195}
196
197pub async fn token_revoke(pool: &PgPool, id: Uuid) -> anyhow::Result<()> {
198    let res = sqlx::query("UPDATE api_tokens SET revoked_at = now() WHERE id = $1")
199        .bind(id)
200        .execute(pool)
201        .await?;
202    if res.rows_affected() == 0 {
203        bail!("no token with id {id}");
204    }
205    println!("token {id} revoked");
206    Ok(())
207}
208
209/// Print the exact `.mcp.json` block a teammate drops into their repo.
210pub fn print_mcp_config(url: &str, token: &str) {
211    let cfg = serde_json::json!({
212        "mcpServers": {
213            "ai-crew-sync": {
214                "type": "http",
215                "url": url,
216                "headers": { "Authorization": format!("Bearer {token}") }
217            }
218        }
219    });
220    println!("{}", serde_json::to_string_pretty(&cfg).unwrap());
221}