1use 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
209pub 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}