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
52fn human_bytes(n: i64) -> String {
53 const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
54 let mut value = n as f64;
55 let mut unit = 0;
56 while value >= 1024.0 && unit < UNITS.len() - 1 {
57 value /= 1024.0;
58 unit += 1;
59 }
60 if unit == 0 {
61 format!("{n} B")
62 } else {
63 format!("{value:.1} {}", UNITS[unit])
64 }
65}
66
67pub async fn team_quota(pool: &PgPool, team: &str, bytes: Option<i64>) -> anyhow::Result<()> {
69 let id = team_id(pool, team).await?;
70 if let Some(b) = bytes
71 && b <= 0
72 {
73 anyhow::bail!("a quota must be positive; omit --bytes to clear it");
74 }
75 sqlx::query("UPDATE teams SET attachment_bytes_limit = $1 WHERE id = $2")
76 .bind(bytes)
77 .bind(id)
78 .execute(pool)
79 .await?;
80 match bytes {
81 Some(b) => println!("team '{team}' attachment quota set to {}", human_bytes(b)),
82 None => println!("team '{team}' attachment quota cleared (unlimited)"),
83 }
84 Ok(())
85}
86
87pub async fn team_usage(pool: &PgPool, team: &str) -> anyhow::Result<()> {
90 let id = team_id(pool, team).await?;
91 let u = crate::store::quota::usage(pool, id).await?;
92
93 let quota = match u.attachment_bytes_limit {
94 Some(limit) => format!(
95 "{} of {} ({:.1}%)",
96 human_bytes(u.attachment_bytes),
97 human_bytes(limit),
98 u.percent_used().unwrap_or(0.0)
99 ),
100 None => format!("{} (no quota set)", human_bytes(u.attachment_bytes)),
101 };
102
103 println!("team '{team}'");
104 println!(
105 " attachments {quota} across {} file(s)",
106 u.attachment_count
107 );
108 println!(" messages {}", u.messages);
109 println!(" note revisions {}", u.note_revisions);
110 println!(" task events {}", u.task_events);
111 if let Some(oldest) = u.oldest_message {
112 let days = (chrono::Utc::now() - oldest).num_days();
113 println!(" oldest message {days} day(s) ago");
114 }
115 if let Some(pct) = u.percent_used()
116 && pct >= 80.0
117 {
118 println!();
119 println!(" ⚠ {pct:.0}% of the attachment quota is in use — raise it with");
120 println!(" `team quota --team {team} --bytes N`, or free space with `team prune`.");
121 }
122 Ok(())
123}
124
125pub async fn team_prune(pool: &PgPool, team: &str, days: i64, apply: bool) -> anyhow::Result<()> {
127 let id = team_id(pool, team).await?;
128 let report = crate::store::quota::prune(pool, id, days, !apply).await?;
129
130 let verb = if report.dry_run {
131 "would delete"
132 } else {
133 "deleted"
134 };
135 println!("team '{team}', anything older than {days} day(s):");
136 println!(" {verb} {} message(s)", report.messages);
137 println!(" {verb} {} note revision(s)", report.note_revisions);
138 println!(" {verb} {} task event(s)", report.task_events);
139 println!(
140 " {verb} attachments worth {}",
141 human_bytes(report.attachments_freed_bytes)
142 );
143 if report.dry_run {
144 println!();
145 println!("dry run — nothing was deleted. Re-run with --apply to do it.");
146 println!("Notes and tasks themselves are never pruned, only their history.");
147 }
148 Ok(())
149}
150
151pub async fn agent_add(
152 pool: &PgPool,
153 team: &str,
154 name: &str,
155 display_name: Option<String>,
156 issue_token: bool,
157) -> anyhow::Result<()> {
158 let tid = team_id(pool, team).await?;
159 let name = name.trim().to_lowercase();
160 if name.is_empty() {
161 bail!("agent name cannot be empty");
162 }
163
164 sqlx::query(
165 r#"
166 INSERT INTO agents (team_id, name, display_name)
167 VALUES ($1, $2, $3)
168 ON CONFLICT (team_id, name) DO UPDATE
169 SET display_name = COALESCE(EXCLUDED.display_name, agents.display_name),
170 disabled_at = NULL
171 "#,
172 )
173 .bind(tid)
174 .bind(&name)
175 .bind(display_name)
176 .execute(pool)
177 .await?;
178 println!("agent '{name}' ready in team '{team}'");
179
180 if issue_token {
181 token_issue(pool, team, &name, None).await?;
182 }
183 Ok(())
184}
185
186pub async fn agent_list(pool: &PgPool, team: &str) -> anyhow::Result<()> {
187 let tid = team_id(pool, team).await?;
188 let rows: Vec<(String, Option<String>, bool, i64)> = sqlx::query_as(
189 r#"
190 SELECT a.name,
191 a.display_name,
192 (a.disabled_at IS NOT NULL) AS disabled,
193 (SELECT count(*) FROM api_tokens t
194 WHERE t.agent_id = a.id AND t.revoked_at IS NULL)
195 FROM agents a WHERE a.team_id = $1 ORDER BY a.name
196 "#,
197 )
198 .bind(tid)
199 .fetch_all(pool)
200 .await?;
201 for (name, display, disabled, tokens) in rows {
202 let flag = if disabled { " [disabled]" } else { "" };
203 println!(
204 "{name:<24} {:<28} {tokens} active token(s){flag}",
205 display.unwrap_or_default()
206 );
207 }
208 Ok(())
209}
210
211pub async fn agent_disable(pool: &PgPool, team: &str, name: &str) -> anyhow::Result<()> {
212 let tid = team_id(pool, team).await?;
213 let res = sqlx::query("UPDATE agents SET disabled_at = now() WHERE team_id = $1 AND name = $2")
214 .bind(tid)
215 .bind(name.trim())
216 .execute(pool)
217 .await?;
218 if res.rows_affected() == 0 {
219 bail!("no agent '{name}' in team '{team}'");
220 }
221 println!("agent '{name}' disabled; its tokens no longer authenticate");
222 Ok(())
223}
224
225pub async fn token_issue(
226 pool: &PgPool,
227 team: &str,
228 agent: &str,
229 label: Option<String>,
230) -> anyhow::Result<()> {
231 let tid = team_id(pool, team).await?;
232 let row: Option<(Uuid,)> =
233 sqlx::query_as("SELECT id FROM agents WHERE team_id = $1 AND name = $2")
234 .bind(tid)
235 .bind(agent.trim())
236 .fetch_optional(pool)
237 .await?;
238 let Some((agent_id,)) = row else {
239 bail!("no agent '{agent}' in team '{team}' — add it with `agent add` first");
240 };
241
242 let raw = generate_token();
243 sqlx::query(
244 "INSERT INTO api_tokens (agent_id, token_hash, prefix, label) VALUES ($1, $2, $3, $4)",
245 )
246 .bind(agent_id)
247 .bind(hash_token(&raw))
248 .bind(token_prefix(&raw))
249 .bind(label)
250 .execute(pool)
251 .await?;
252
253 println!();
254 println!("Token for {agent}@{team} — shown once, store it now:");
255 println!();
256 println!(" {raw}");
257 println!();
258 Ok(())
259}
260
261pub async fn token_list(pool: &PgPool, team: &str) -> anyhow::Result<()> {
262 let tid = team_id(pool, team).await?;
263 let rows: Vec<(
264 Uuid,
265 String,
266 String,
267 Option<String>,
268 Option<chrono::DateTime<chrono::Utc>>,
269 bool,
270 )> = sqlx::query_as(
271 r#"
272 SELECT t.id, a.name, t.prefix, t.label, t.last_used_at,
273 (t.revoked_at IS NOT NULL) AS revoked
274 FROM api_tokens t
275 JOIN agents a ON a.id = t.agent_id
276 WHERE a.team_id = $1
277 ORDER BY a.name, t.created_at
278 "#,
279 )
280 .bind(tid)
281 .fetch_all(pool)
282 .await?;
283 for (id, agent, prefix, label, last_used, revoked) in rows {
284 let used = last_used
285 .map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
286 .unwrap_or_else(|| "never".into());
287 let flag = if revoked { " [revoked]" } else { "" };
288 println!(
289 "{id} {agent:<20} {prefix}… last used {used} {}{flag}",
290 label.unwrap_or_default()
291 );
292 }
293 Ok(())
294}
295
296pub async fn token_revoke(pool: &PgPool, id: Uuid) -> anyhow::Result<()> {
297 let res = sqlx::query("UPDATE api_tokens SET revoked_at = now() WHERE id = $1")
298 .bind(id)
299 .execute(pool)
300 .await?;
301 if res.rows_affected() == 0 {
302 bail!("no token with id {id}");
303 }
304 println!("token {id} revoked");
305 Ok(())
306}
307
308pub fn print_mcp_config(url: &str, token: &str) {
310 let cfg = serde_json::json!({
311 "mcpServers": {
312 "ai-crew-sync": {
313 "type": "http",
314 "url": url,
315 "headers": { "Authorization": format!("Bearer {token}") }
316 }
317 }
318 });
319 println!("{}", serde_json::to_string_pretty(&cfg).unwrap());
320}