1use 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
36pub 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
53pub 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#[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
98pub 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 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 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 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 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
311pub 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 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 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
409pub 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
457pub 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
477pub 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 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
550pub 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
667pub 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 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 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
715pub 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
743pub 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
793pub 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 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}