Skip to main content

ai_crew_sync/
admin.rs

1//! Operator-facing commands. These bypass MCP entirely and talk to Postgres
2//! directly. They remain the emergency path and the only way to bootstrap the
3//! first administrative credential; day-to-day administration goes through
4//! the remote API with `ai-crew-sync admin …`.
5//!
6//! Every mutation is delegated to [`crate::store::admin`] with
7//! [`Actor::Cli`], so the audit trail is the same whichever door was used.
8
9use anyhow::bail;
10use sqlx::PgPool;
11use uuid::Uuid;
12
13use crate::store::admin::{self as store, Actor};
14
15async fn team_id(pool: &PgPool, slug: &str) -> anyhow::Result<Uuid> {
16    Ok(store::team_id_by_slug(pool, slug).await?)
17}
18
19pub async fn team_create(pool: &PgPool, slug: &str, name: Option<String>) -> anyhow::Result<()> {
20    let team = store::create_team(pool, Actor::Cli, slug, name).await?;
21    println!("team '{}' ready", team.slug);
22    Ok(())
23}
24
25pub async fn team_list(pool: &PgPool) -> anyhow::Result<()> {
26    let rows = store::list_teams(pool).await?;
27    if rows.is_empty() {
28        println!("(no teams yet — create one with `team create --slug <slug>`)");
29    }
30    for t in rows {
31        println!("{:<20} {:<30} {} agent(s)", t.slug, t.name, t.agents);
32    }
33    Ok(())
34}
35
36/// Turn conversations on or off for a team.
37pub async fn team_capability(pool: &PgPool, team: &str, conversations: bool) -> anyhow::Result<()> {
38    let id = team_id(pool, team).await?;
39    store::set_conversations(pool, Actor::Cli, id, conversations).await?;
40    println!(
41        "team '{team}': conversations {}",
42        if conversations { "enabled" } else { "disabled" }
43    );
44    if conversations {
45        println!(
46            "Agents of this team now see create_conversation and the rest. Existing tools \
47             are unchanged."
48        );
49    }
50    Ok(())
51}
52
53/// Route a team's new conversations to a backend, and say plainly what that
54/// does and does not do.
55pub async fn team_backend(pool: &PgPool, team: &str, backend: &str) -> anyhow::Result<()> {
56    let id = team_id(pool, team).await?;
57    store::set_default_backend(pool, Actor::Cli, id, backend).await?;
58    println!("team '{team}': new conversations are created on '{backend}'");
59    println!(
60        "Existing threads keep the backend they were created on. Nothing was migrated, and \
61         nothing will be by this command."
62    );
63    if backend == "jetstream" {
64        println!(
65            "Check before anyone writes: the stream exists (`ai-crew-sync team stream --team \
66             {team} --nats-url ...`) and the server was started with --nats-url. Without \
67             both, sends are accepted and stay pending."
68        );
69    }
70    Ok(())
71}
72
73/// Create or remove a team's stream. An operator action with its own
74/// credential; the server process deliberately cannot do it.
75/// The ceilings `team stream` asks for, per stream. Reserved against the
76/// broker's `max_file_store` when a stream is created, whether or not the
77/// bytes are ever used.
78#[derive(Clone, Copy, Debug)]
79pub struct StreamQuotas {
80    pub max_bytes: i64,
81    pub max_messages: i64,
82    pub inbox_max_bytes: i64,
83    pub inbox_max_messages: i64,
84}
85
86impl Default for StreamQuotas {
87    fn default() -> Self {
88        use crate::store::jetstream::*;
89        Self {
90            max_bytes: DEFAULT_MAX_BYTES,
91            max_messages: DEFAULT_MAX_MESSAGES,
92            inbox_max_bytes: DEFAULT_INBOX_MAX_BYTES,
93            inbox_max_messages: DEFAULT_INBOX_MAX_MESSAGES,
94        }
95    }
96}
97
98/// Create a team's streams, report their effective limits, or change them.
99///
100/// A stream that already exists keeps its limits on a routine call: the
101/// command says which limits it has and that `--update-quotas` is the way
102/// to change them. With `update` the requested quotas are applied to both
103/// streams, refused where a stream already holds more than the new
104/// ceiling. Bodies and references are never touched by either path.
105pub async fn team_stream(
106    pool: &PgPool,
107    team: &str,
108    nats_url: &str,
109    credentials: Option<String>,
110    remove: bool,
111    quotas: StreamQuotas,
112    update: bool,
113) -> anyhow::Result<()> {
114    use crate::store::jetstream::{
115        JetStreamBackend, Provisioned, QuotaChange, StreamKind, format_size,
116    };
117    let id = team_id(pool, team).await?;
118    let mut config = crate::store::jetstream::Config::new(nats_url.to_owned())
119        .with_limits(quotas.max_messages, quotas.max_bytes)
120        .with_inbox_limits(quotas.inbox_max_messages, quotas.inbox_max_bytes);
121    config.credentials = credentials;
122    config.validate_quotas()?;
123    if remove {
124        let (routed,): (String,) =
125            sqlx::query_as("SELECT default_backend FROM teams WHERE id = $1")
126                .bind(id)
127                .fetch_one(pool)
128                .await?;
129        if routed == "jetstream" {
130            anyhow::bail!(
131                "team '{team}' still creates its conversations on JetStream. Route it back \
132                 with `team capability --backend postgres` first; deleting the stream now \
133                 would drop bodies its threads still point at."
134            );
135        }
136        // Routing new threads elsewhere says nothing about the ones already
137        // there. A conversation keeps the backend it was created on, so its
138        // bodies are still in this stream and deleting it would turn every
139        // one of them into a tombstone.
140        let (still_there,): (i64,) = sqlx::query_as(
141            "SELECT count(*) FROM conversations WHERE team_id = $1 AND backend = 'jetstream'",
142        )
143        .bind(id)
144        .fetch_one(pool)
145        .await?;
146        if still_there > 0 {
147            anyhow::bail!(
148                "{still_there} conversation(s) of team '{team}' still keep their bodies in \
149                 this stream. Deleting it now would turn every one of those messages into \
150                 a tombstone, and no rollback brings them back."
151            );
152        }
153        crate::store::jetstream::JetStreamBackend::deprovision(&config, id).await?;
154        println!("team '{team}': stream removed, with every body it held");
155        return Ok(());
156    }
157    // Two streams, because bodies and references cannot share a retention
158    // policy: history must not be dropped, and references are a cache whose
159    // truth is in Postgres.
160    let bodies = JetStreamBackend::provision(&config, id).await?;
161    let inbox = JetStreamBackend::provision_inbox(&config, id).await?;
162    let plan = [
163        (
164            bodies,
165            StreamKind::Bodies,
166            quotas.max_messages,
167            quotas.max_bytes,
168        ),
169        (
170            inbox,
171            StreamKind::Inbox,
172            quotas.inbox_max_messages,
173            quotas.inbox_max_bytes,
174        ),
175    ];
176    let mut outcomes: Vec<(Provisioned, &str)> = Vec::with_capacity(2);
177    let mut kept = Vec::new();
178    if update {
179        // Every change is vetted before any is applied, and the sum of what
180        // they would newly reserve is checked against the account's budget
181        // when it has one (a server-level max_file_store is not visible
182        // here; the broker refuses at apply time and the rollback below
183        // covers it): one command must not leave a team with one stream
184        // changed and the other refused.
185        let mut changes = Vec::new();
186        for (outcome, kind, want_messages, want_bytes) in &plan {
187            if outcome.differs_from(*want_messages, *want_bytes) {
188                changes.push((
189                    *kind,
190                    JetStreamBackend::check_update(&config, id, *kind).await?,
191                ));
192            }
193        }
194        let additional: i64 = changes.iter().map(|(_, c)| c.additional_bytes()).sum();
195        if additional > 0 {
196            let account = JetStreamBackend::storage_account(&config).await?;
197            if !account.fits(additional) {
198                anyhow::bail!(
199                    "the requested quotas need {} ({}) more reserved than the streams have now, \
200                     and {}. Nothing was changed. Ask for smaller quotas, lower another \
201                     stream's, remove one, or raise the broker's max_file_store.",
202                    additional,
203                    format_size(additional),
204                    account.describe()
205                );
206            }
207        }
208        let mut applied: Vec<(StreamKind, &QuotaChange)> = Vec::new();
209        for (kind, change) in &changes {
210            match JetStreamBackend::apply_update(&config, id, *kind, change).await {
211                Ok(outcome) => {
212                    applied.push((*kind, change));
213                    outcomes.push((outcome, "updated"));
214                }
215                Err(e) => {
216                    // Put back what was already changed, so the team never
217                    // ends up half way between two quota sets.
218                    let mut restored = Vec::new();
219                    for (done_kind, done) in &applied {
220                        let previous = match done_kind {
221                            StreamKind::Bodies => config
222                                .clone()
223                                .with_limits(done.current_max_messages, done.current_max_bytes),
224                            StreamKind::Inbox => config.clone().with_inbox_limits(
225                                done.current_max_messages,
226                                done.current_max_bytes,
227                            ),
228                        };
229                        match JetStreamBackend::update_quotas(&previous, id, *done_kind).await {
230                            Ok(_) => restored.push(done.name.clone()),
231                            Err(back) => anyhow::bail!(
232                                "updating '{}' failed ({e}) and restoring '{}' to its previous \
233                                 limits failed too ({back}); the team's quotas are now mixed. \
234                                 Re-run with --update-quotas once the cause is fixed.",
235                                change.name,
236                                done.name
237                            ),
238                        }
239                    }
240                    if restored.is_empty() {
241                        anyhow::bail!("{e}\nNothing was changed.");
242                    }
243                    anyhow::bail!(
244                        "{e}\nRestored '{}' to its previous limits; nothing was changed.",
245                        restored.join("', '")
246                    );
247                }
248            }
249        }
250    }
251    for (outcome, _, want_messages, want_bytes) in plan {
252        if outcomes.iter().any(|(o, _)| o.name == outcome.name) {
253            continue;
254        }
255        if outcome.differs_from(want_messages, want_bytes) && !outcome.created {
256            kept.push(format!(
257                "'{}' keeps {} messages / {} (you asked for {} / {})",
258                outcome.name,
259                outcome.max_messages,
260                format_size(outcome.max_bytes),
261                want_messages,
262                format_size(want_bytes)
263            ));
264        }
265        let verb = if outcome.created { "created" } else { "exists" };
266        outcomes.push((outcome, verb));
267    }
268    for (outcome, verb) in &outcomes {
269        let what = if outcome.name.starts_with("ACS_I_") {
270            "inbox references"
271        } else {
272            "bodies"
273        };
274        println!(
275            "team '{team}': '{}' ({what}) {verb}: up to {} messages / {} ({} bytes), holding {} \
276             messages / {}",
277            outcome.name,
278            outcome.max_messages,
279            format_size(outcome.max_bytes),
280            outcome.max_bytes,
281            outcome.messages,
282            format_size(outcome.bytes as i64)
283        );
284        if *verb == "updated" && outcome.over_ceiling() {
285            println!(
286                "  note: '{}' grew past the new ceiling while it was being changed (a publisher \
287                 got in between). Nothing was lost: the body stream refuses new writes until \
288                 pruned, and a dropped inbox reference is rebuilt from Postgres.",
289                outcome.name
290            );
291        }
292    }
293    if !kept.is_empty() {
294        println!(
295            "Existing limits were kept: {}. Pass --update-quotas to apply the requested ones; \
296             a ceiling below what a stream already holds is refused.",
297            kept.join("; ")
298        );
299    }
300    println!(
301        "Reservation note: every stream's max_bytes counts against the broker's max_file_store \
302         from creation, used or not."
303    );
304    println!(
305        "This routes nobody. `team capability --team {team} --backend jetstream` is what \
306         sends new conversations there."
307    );
308    Ok(())
309}
310
311/// Plan, and optionally run, a supervised move of conversation bodies.
312pub async fn conversations_migrate(
313    pool: &PgPool,
314    team: &str,
315    to: &str,
316    conversations: &[String],
317    nats_url: &str,
318    credentials: Option<String>,
319    apply: bool,
320) -> anyhow::Result<()> {
321    use crate::store::migrate::{self, Direction};
322
323    let id = team_id(pool, team).await?;
324    let direction = Direction::parse(to)?;
325    let only: Vec<Uuid> = conversations
326        .iter()
327        .map(|c| {
328            c.trim()
329                .parse::<Uuid>()
330                .map_err(|_| anyhow::anyhow!("'{c}' is not a conversation id"))
331        })
332        .collect::<anyhow::Result<_>>()?;
333
334    let mut config = crate::store::jetstream::Config::new(nats_url.to_owned());
335    config.credentials = credentials;
336    // Fail here rather than halfway through: a move that cannot reach the
337    // broker is a move that should not start.
338    let jetstream = crate::store::jetstream::JetStreamBackend::connect(&config, id).await?;
339
340    let plans = migrate::plan(pool, id, direction, &only).await?;
341    if plans.is_empty() {
342        println!("team '{team}': nothing matches");
343        return Ok(());
344    }
345    println!("team '{team}' → {}", direction.target());
346    for p in &plans {
347        println!(
348            "  {} {:<30} {} message(s), {}{}",
349            p.conversation_id,
350            p.title.chars().take(30).collect::<String>(),
351            p.messages,
352            human_bytes(p.bytes),
353            match (&p.blocked, p.resuming) {
354                (Some(why), _) => format!("  — skipped: {why}"),
355                (None, true) => "  — resuming an interrupted move".to_owned(),
356                (None, false) => String::new(),
357            }
358        );
359    }
360    if !apply {
361        println!();
362        println!("Dry run. Nothing was moved. Add --apply to run it.");
363        println!(
364            "This moves message bodies only. Attachments, memberships, receipts and ids \
365             stay exactly where and as they are."
366        );
367        println!(
368            "Each thread pauses writes only while its own tail is copied and verified; reads keep working throughout, and the rest of the bus is untouched."
369        );
370        return Ok(());
371    }
372
373    for p in plans.iter().filter(|p| p.blocked.is_none()) {
374        print!("  {} … ", p.conversation_id);
375        use std::io::Write as _;
376        let _ = std::io::stdout().flush();
377        match migrate::run(pool, &jetstream, id, p.conversation_id, direction).await {
378            Ok(o) => println!(
379                "moved {} message(s), {} already there, {} verified",
380                o.copied,
381                o.skipped,
382                human_bytes(o.bytes)
383            ),
384            Err(e) => {
385                println!("FAILED: {e}");
386                // Said from the thread's real state: an abort that could not
387                // finish leaves the move open and the thread paused.
388                match migrate::is_paused(pool, p.conversation_id).await {
389                    Ok(false) => println!(
390                        "      Nothing was cut over for this thread and its writes are open again. Fix the cause and run the same command: what is already verified is not copied twice."
391                    ),
392                    Ok(true) => println!(
393                        "      Nothing was cut over, but this thread is still paused under its open move. Fix the cause and run the same command: it resumes that move, copies nothing twice and reopens the thread."
394                    ),
395                    Err(check) => println!(
396                        "      Nothing was cut over, and whether this thread is paused could not be checked ({check}). Run the same command once the database answers: it resumes an open move, or reports the thread as it is."
397                    ),
398                }
399            }
400        }
401    }
402    println!();
403    println!(
404        "Source bodies are kept. `conversations cleanup` drops them later, once you are sure you will not roll back."
405    );
406    Ok(())
407}
408
409/// Drop source bodies whose move is old enough to trust.
410pub async fn conversations_cleanup(
411    pool: &PgPool,
412    team: &str,
413    rollback_window_hours: i64,
414    apply: bool,
415) -> anyhow::Result<()> {
416    let id = team_id(pool, team).await?;
417    let (count, bytes) =
418        crate::store::migrate::cleanup(pool, id, rollback_window_hours, apply).await?;
419    if count == 0 {
420        println!(
421            "team '{team}': nothing to drop (no move finished more than \
422             {rollback_window_hours}h ago)"
423        );
424        return Ok(());
425    }
426    if apply {
427        println!(
428            "team '{team}': dropped {count} source body(ies), {} freed in Postgres",
429            human_bytes(bytes)
430        );
431        println!("Rolling those threads back now needs the broker, not an older image.");
432    } else {
433        println!(
434            "team '{team}': {count} source body(ies) could be dropped, {} in Postgres",
435            human_bytes(bytes)
436        );
437        println!("Dry run. Add --apply to delete them.");
438    }
439    Ok(())
440}
441
442fn human_bytes(n: i64) -> String {
443    const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
444    let mut value = n as f64;
445    let mut unit = 0;
446    while value >= 1024.0 && unit < UNITS.len() - 1 {
447        value /= 1024.0;
448        unit += 1;
449    }
450    if unit == 0 {
451        format!("{n} B")
452    } else {
453        format!("{value:.1} {}", UNITS[unit])
454    }
455}
456
457/// Set or clear a team's attachment quota. `None` clears it (unlimited).
458pub async fn team_quota(pool: &PgPool, team: &str, bytes: Option<i64>) -> anyhow::Result<()> {
459    let id = team_id(pool, team).await?;
460    if let Some(b) = bytes
461        && b <= 0
462    {
463        anyhow::bail!("a quota must be positive; omit --bytes to clear it");
464    }
465    sqlx::query("UPDATE teams SET attachment_bytes_limit = $1 WHERE id = $2")
466        .bind(bytes)
467        .bind(id)
468        .execute(pool)
469        .await?;
470    match bytes {
471        Some(b) => println!("team '{team}' attachment quota set to {}", human_bytes(b)),
472        None => println!("team '{team}' attachment quota cleared (unlimited)"),
473    }
474    Ok(())
475}
476
477/// Report what a team is storing. Counts and bytes only — never content, so
478/// this is safe to run for a team you are not on.
479pub async fn team_usage(pool: &PgPool, team: &str) -> anyhow::Result<()> {
480    let id = team_id(pool, team).await?;
481    let u = crate::store::quota::usage(pool, id).await?;
482
483    let quota = match u.attachment_bytes_limit {
484        Some(limit) => format!(
485            "{} of {} ({:.1}%)",
486            human_bytes(u.attachment_bytes),
487            human_bytes(limit),
488            u.percent_used().unwrap_or(0.0)
489        ),
490        None => format!("{} (no quota set)", human_bytes(u.attachment_bytes)),
491    };
492
493    println!("team '{team}'");
494    println!(
495        "  attachments {quota} across {} file(s)",
496        u.attachment_count
497    );
498    println!("  messages {}", u.messages);
499    println!("  note revisions  {}", u.note_revisions);
500    println!("  task events {}", u.task_events);
501    if let Some(oldest) = u.oldest_message {
502        let days = (chrono::Utc::now() - oldest).num_days();
503        println!("  oldest message  {days} day(s) ago");
504    }
505    // Publication, for a team routed off Postgres. Shown only when there is
506    // something to show: on the default backend the queue is always empty
507    // and a permanent "0 pending" line is noise.
508    let (backend,): (String,) = sqlx::query_as("SELECT default_backend FROM teams WHERE id = $1")
509        .bind(id)
510        .fetch_one(pool)
511        .await?;
512    let outbox = crate::store::outbox::status(pool, id).await?;
513    if backend != "postgres" || outbox.pending + outbox.leased + outbox.failed > 0 {
514        println!("  backend {backend}");
515        println!(
516            "  publication {} pending, {} in flight, {} failed ({})",
517            outbox.pending,
518            outbox.leased,
519            outbox.failed,
520            human_bytes(outbox.pending_bytes)
521        );
522        if let Some(secs) = outbox.oldest_pending_seconds
523            && secs > 300
524        {
525            println!(
526                "  ⚠ the oldest unpublished message is {} minute(s) old. Check the broker \
527                 and that a replica is draining (--publication-worker).",
528                secs / 60
529            );
530        }
531        if outbox.failed > 0 {
532            println!(
533                "  ⚠ {} message(s) will not be published. Their slots stay so the gap is \
534                 visible; readers are told.",
535                outbox.failed
536            );
537        }
538    }
539
540    if let Some(pct) = u.percent_used()
541        && pct >= 80.0
542    {
543        println!();
544        println!("  ⚠ {pct:.0}% of the attachment quota is in use — raise it with");
545        println!("    `team quota --team {team} --bytes N`, or free space with `team prune`.");
546    }
547    Ok(())
548}
549
550/// Trim history older than `days`. Dry run by default at the call site.
551pub async fn team_prune(pool: &PgPool, team: &str, days: i64, apply: bool) -> anyhow::Result<()> {
552    let id = team_id(pool, team).await?;
553    let report = crate::store::quota::prune(pool, id, days, !apply).await?;
554
555    let verb = if report.dry_run {
556        "would delete"
557    } else {
558        "deleted"
559    };
560    println!("team '{team}', anything older than {days} day(s):");
561    println!("  {verb} {} message(s)", report.messages);
562    println!("  {verb} {} note revision(s)", report.note_revisions);
563    println!("  {verb} {} task event(s)", report.task_events);
564    println!(
565        "  {verb} attachments worth {}",
566        human_bytes(report.attachments_freed_bytes)
567    );
568    if report.dry_run {
569        println!();
570        println!("dry run — nothing was deleted. Re-run with --apply to do it.");
571        println!("Notes and tasks themselves are never pruned, only their history.");
572    }
573    Ok(())
574}
575
576pub async fn agent_add(
577    pool: &PgPool,
578    team: &str,
579    name: &str,
580    display_name: Option<String>,
581    issue_token: bool,
582) -> anyhow::Result<()> {
583    let tid = team_id(pool, team).await?;
584    let agent = store::create_agent(pool, Actor::Cli, tid, name, display_name).await?;
585    println!("agent '{}' ready in team '{team}'", agent.name);
586
587    if issue_token {
588        token_issue(pool, team, &agent.name, None).await?;
589    }
590    Ok(())
591}
592
593pub async fn agent_list(pool: &PgPool, team: &str) -> anyhow::Result<()> {
594    let tid = team_id(pool, team).await?;
595    for a in store::list_agents(pool, tid).await? {
596        let flag = if a.disabled { " [disabled]" } else { "" };
597        println!(
598            "{:<24} {:<28} {} active token(s){flag}",
599            a.name,
600            a.display_name.unwrap_or_default(),
601            a.active_tokens
602        );
603    }
604    Ok(())
605}
606
607pub async fn agent_disable(pool: &PgPool, team: &str, name: &str) -> anyhow::Result<()> {
608    let tid = team_id(pool, team).await?;
609    match store::disable_agent(pool, Actor::Cli, tid, name).await {
610        Err(crate::error::BusError::NotFound(_)) => bail!("no agent '{name}' in team '{team}'"),
611        other => other?,
612    }
613    println!("agent '{name}' disabled; its tokens no longer authenticate");
614    Ok(())
615}
616
617pub async fn token_issue(
618    pool: &PgPool,
619    team: &str,
620    agent: &str,
621    label: Option<String>,
622) -> anyhow::Result<()> {
623    let tid = team_id(pool, team).await?;
624    let issued = match store::issue_token(pool, Actor::Cli, tid, agent, label).await {
625        Err(crate::error::BusError::NotFound(_)) => {
626            bail!("no agent '{agent}' in team '{team}' — add it with `agent add` first")
627        }
628        other => other?,
629    };
630
631    println!();
632    println!("Token for {agent}@{team} — shown once, store it now:");
633    println!();
634    println!("  {}", issued.token);
635    println!();
636    Ok(())
637}
638
639pub async fn token_list(pool: &PgPool, team: &str) -> anyhow::Result<()> {
640    let tid = team_id(pool, team).await?;
641    for t in store::list_tokens(pool, tid).await? {
642        let used = t
643            .last_used_at
644            .map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
645            .unwrap_or_else(|| "never".into());
646        let flag = if t.revoked { " [revoked]" } else { "" };
647        println!(
648            "{}  {:<20} {}…  last used {used}  {}{flag}",
649            t.id,
650            t.agent,
651            t.prefix,
652            t.label.unwrap_or_default()
653        );
654    }
655    Ok(())
656}
657
658pub async fn token_revoke(pool: &PgPool, id: Uuid) -> anyhow::Result<()> {
659    match store::revoke_token(pool, Actor::Cli, None, id).await {
660        Err(crate::error::BusError::NotFound(_)) => bail!("no token with id {id}"),
661        other => other?,
662    }
663    println!("token {id} revoked");
664    Ok(())
665}
666
667// ------------------------------------------------- administrative credentials --
668
669/// Mint a global administrative credential. The one operation that needs a
670/// database connection and no prior credential: everything else can be done
671/// remotely with the credential this prints.
672pub async fn admin_bootstrap(pool: &PgPool, label: Option<String>) -> anyhow::Result<()> {
673    let issued = store::grant_admin(pool, Actor::Cli, None, label).await?;
674
675    // The secret first: nothing that can fail stands between the mint and
676    // the one time it is shown.
677    println!();
678    println!("Global administrative credential — shown once, store it now:");
679    println!();
680    println!("  {}", issued.token);
681    println!();
682    println!("Use it from your machine with `ai-crew-sync admin login --url <bus>`.");
683
684    // Informational; a failure here must not look like a failed bootstrap.
685    match store::list_admins(pool, None).await {
686        Ok(rows) => {
687            let active = rows
688                .iter()
689                .filter(|c| c.team.is_none() && !c.revoked)
690                .count();
691            println!(
692                "{active} global credential(s) are now active; list them with `admin credential list`."
693            );
694        }
695        Err(e) => eprintln!("(could not count active credentials: {e})"),
696    }
697    Ok(())
698}
699
700pub async fn admin_credential_list(pool: &PgPool, team: Option<&str>) -> anyhow::Result<()> {
701    let tid = match team {
702        Some(slug) => Some(team_id(pool, slug).await?),
703        None => None,
704    };
705    let rows = store::list_admins(pool, tid).await?;
706    if rows.is_empty() {
707        println!("(no administrative credentials — mint the first with `admin bootstrap`)");
708    }
709    for c in rows {
710        print_admin_row(&c);
711    }
712    Ok(())
713}
714
715/// One line per credential, shared with the remote CLI so both listings read
716/// the same.
717pub fn print_admin_row(c: &store::AdminRow) {
718    let used = c
719        .last_used_at
720        .map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
721        .unwrap_or_else(|| "never".into());
722    let scope = c.team.as_deref().unwrap_or("(global)");
723    let flag = if c.revoked { " [revoked]" } else { "" };
724    println!(
725        "{}  {scope:<20} {}…  last used {used}  {}{flag}",
726        c.id,
727        c.prefix,
728        c.label.as_deref().unwrap_or_default()
729    );
730}
731
732pub async fn admin_credential_revoke(pool: &PgPool, id: Uuid) -> anyhow::Result<()> {
733    match store::revoke_admin(pool, Actor::Cli, None, id).await {
734        Err(crate::error::BusError::NotFound(_)) => {
735            bail!("no administrative credential with id {id}")
736        }
737        other => other?,
738    }
739    println!("administrative credential {id} revoked");
740    Ok(())
741}
742
743/// Print the client configuration for the per-conversation stdio proxy.
744///
745/// `format` is `json` (the `.mcp.json` shape most MCP clients use) or `toml`
746/// (Codex's `~/.codex/config.toml`). Neither carries a credential: the proxy
747/// resolves one from the local profiles.
748pub fn print_proxy_config(
749    format: &str,
750    role: Option<&str>,
751    project: Option<&str>,
752    profile: Option<&str>,
753) {
754    let mut args: Vec<String> = vec!["mcp".into(), "proxy".into()];
755    for (flag, value) in [
756        ("--role", role),
757        ("--project", project),
758        ("--profile", profile),
759    ] {
760        if let Some(v) = value.map(str::trim).filter(|v| !v.is_empty()) {
761            args.push(flag.into());
762            args.push(v.into());
763        }
764    }
765    let exe = "ai-crew-sync";
766    match format {
767        "toml" => {
768            println!("# ~/.codex/config.toml (or <repo>/.codex/config.toml in a trusted project)");
769            println!("[mcp_servers.ai-crew-sync]");
770            println!("command = \"{exe}\"");
771            println!(
772                "args = [{}]",
773                args.iter()
774                    .map(|a| format!("\"{a}\""))
775                    .collect::<Vec<_>>()
776                    .join(", ")
777            );
778            println!();
779            println!("# No token here: credentials come from your local profiles");
780            println!("# (`ai-crew-sync context profile add`), never from this file.");
781        }
782        _ => {
783            let cfg = serde_json::json!({
784                "mcpServers": {
785                    "ai-crew-sync": { "command": exe, "args": args }
786                }
787            });
788            println!("{}", serde_json::to_string_pretty(&cfg).unwrap_or_default());
789        }
790    }
791}
792
793/// Print the exact `.mcp.json` block a teammate drops into their repo.
794pub fn print_mcp_config(url: &str, token: &str, session: Option<&str>) {
795    let mut headers = serde_json::Map::new();
796    headers.insert("Authorization".into(), format!("Bearer {token}").into());
797    // Only when asked for: an empty header would name a session called "",
798    // which is the shared one you get by not sending the header at all.
799    if let Some(session) = session.map(str::trim).filter(|s| !s.is_empty()) {
800        headers.insert(crate::auth::SESSION_HEADER.into(), session.into());
801    }
802    let cfg = serde_json::json!({
803        "mcpServers": {
804            "ai-crew-sync": {
805                "type": "http",
806                "url": url,
807                "headers": headers
808            }
809        }
810    });
811    println!("{}", serde_json::to_string_pretty(&cfg).unwrap());
812}