Skip to main content

exeora_cli/
cli.rs

1use crate::{
2    CLI_VERSION,
3    api::{ApiClient, DeviceView, ToolCallView},
4    auth::{
5        AuthManager, clear_credentials, discover_client, load_credentials, using_file_fallback,
6    },
7    config::{ConfigStore, DEFAULT_GATEWAY, ProjectEntry, WorktreeEntry, WorktreeSyncState},
8    connection::connect_forever,
9    policy::{LocalCommandPolicy, POLICY_FILENAME, PolicyMode, render_policy_toml},
10    worktrees::{self, CreateWorktree},
11};
12use anyhow::{Context, Result, anyhow, bail};
13use clap::{ArgAction, Args, Parser, Subcommand};
14use serde_json::{Value, json};
15use std::{
16    env, fs,
17    path::{Path, PathBuf},
18    sync::Arc,
19};
20use url::Url;
21
22#[derive(Debug, Parser)]
23#[command(name = "exeora", version = CLI_VERSION, disable_version_flag = true, arg_required_else_help = true, about = "Connect AI agents to the development environment on this machine, wherever it runs.")]
24pub struct Cli {
25    #[arg(short = 'v', long = "version", action = ArgAction::Version, help = "Print version")]
26    version: Option<bool>,
27    #[arg(
28        long,
29        global = true,
30        help = "Print machine-readable output instead of drawing on the terminal"
31    )]
32    pub json: bool,
33    #[command(subcommand)]
34    pub command: Commands,
35}
36
37#[derive(Debug, Subcommand)]
38pub enum Commands {
39    #[command(about = "Sign in to Exeora in your browser")]
40    Login(GatewayChoice),
41    #[command(about = "Forget the stored session on this machine")]
42    Logout,
43    #[command(about = "Show or change the Exeora this machine talks to")]
44    Gateway {
45        #[command(subcommand)]
46        command: Option<GatewayCommand>,
47    },
48    #[command(about = "Manage this machine")]
49    Device {
50        #[command(subcommand)]
51        command: DeviceCommand,
52    },
53    #[command(about = "Manage projects on this machine")]
54    Project {
55        #[command(subcommand)]
56        command: ProjectCommand,
57    },
58    #[command(about = "Manage Git worktrees connected to Exeora projects")]
59    Worktree {
60        #[command(subcommand)]
61        command: WorktreeCommand,
62    },
63    #[command(about = "Show or change local Exeora settings")]
64    Config {
65        #[command(subcommand)]
66        command: ConfigCommand,
67    },
68    #[command(
69        about = "Sign in, register this machine if needed, and keep it awake to serve registered projects"
70    )]
71    Connect(ConnectArgs),
72    #[command(about = "Show this machine's registration and projects")]
73    Status,
74    #[command(about = "Show recent tool calls: what ran, who asked and how it ended")]
75    Logs(LogsArgs),
76    #[command(about = "Write an exeora.toml restricting what agents may do in a directory")]
77    Init(InitArgs),
78    #[command(
79        about = "Print the Exeora coding-agent prompt, for a client that cannot fetch it itself"
80    )]
81    Prompt {
82        #[arg(short, long)]
83        account: bool,
84    },
85    #[command(about = "Reconcile this machine's registration and projects with the dashboard")]
86    Sync,
87    #[command(about = "Upgrade this native installation to the latest Exeora CLI")]
88    Upgrade,
89}
90
91#[derive(Debug, Args)]
92pub struct GatewayChoice {
93    #[arg(
94        short = 'g',
95        long,
96        help = "Sign in to this Exeora instead, and remember it"
97    )]
98    gateway: Option<String>,
99    #[arg(short = 'y', long, help = "Do not ask before switching gateway")]
100    yes: bool,
101}
102
103#[derive(Debug, Subcommand)]
104pub enum GatewayCommand {
105    #[command(about = "Talk to a different Exeora, forgetting what belongs to this one")]
106    Use {
107        url: String,
108        #[arg(short = 'y', long)]
109        yes: bool,
110        #[arg(long)]
111        force: bool,
112    },
113    #[command(about = "Go back to https://exeora.dev")]
114    Reset {
115        #[arg(short = 'y', long)]
116        yes: bool,
117        #[arg(long)]
118        force: bool,
119    },
120}
121
122#[derive(Debug, Subcommand)]
123pub enum DeviceCommand {
124    #[command(about = "Register this machine so it can serve tool calls")]
125    Register {
126        #[arg(short, long)]
127        name: Option<String>,
128    },
129    #[command(about = "List your registered machines")]
130    List,
131}
132
133#[derive(Debug, Subcommand)]
134pub enum ProjectCommand {
135    #[command(about = "Register a local directory as a project")]
136    Add {
137        path: Option<PathBuf>,
138        #[arg(short, long)]
139        slug: Option<String>,
140    },
141    #[command(about = "List projects registered on this machine")]
142    List,
143    #[command(about = "Stop serving a project from this machine")]
144    Remove { slug: String },
145}
146
147#[derive(Debug, Subcommand)]
148pub enum WorktreeCommand {
149    #[command(about = "Create a Git worktree and connect it to an Exeora project")]
150    Create {
151        branch: String,
152        #[arg(long = "from")]
153        from_ref: Option<String>,
154        #[arg(long)]
155        #[arg(conflicts_with = "from_ref")]
156        reuse_existing_branch: bool,
157        #[arg(short, long)]
158        project: Option<String>,
159        #[arg(short, long)]
160        name: Option<String>,
161        #[arg(short, long)]
162        slug: Option<String>,
163        #[arg(long)]
164        path: Option<PathBuf>,
165    },
166    #[command(about = "Connect an existing Git worktree to an Exeora project")]
167    Attach {
168        path: PathBuf,
169        #[arg(short, long)]
170        project: Option<String>,
171        #[arg(short, long)]
172        name: Option<String>,
173        #[arg(short, long)]
174        slug: Option<String>,
175    },
176    #[command(about = "List worktrees connected to Exeora")]
177    List {
178        #[arg(short, long, conflicts_with = "all")]
179        project: Option<String>,
180        #[arg(long)]
181        all: bool,
182    },
183    #[command(about = "Disconnect a worktree from Exeora without deleting it")]
184    Detach { selector: String },
185    #[command(about = "Disconnect and remove a Git worktree")]
186    Remove {
187        selector: String,
188        #[arg(long)]
189        force: bool,
190        #[arg(long)]
191        delete_branch: bool,
192    },
193}
194
195#[derive(Debug, Subcommand)]
196pub enum ConfigCommand {
197    Get { key: String },
198    Set { key: String, value: PathBuf },
199    Unset { key: String },
200}
201
202#[derive(Debug, Args)]
203pub struct ConnectArgs {
204    #[arg(short, long)]
205    name: Option<String>,
206    #[arg(long)]
207    reset: bool,
208    #[arg(short = 'g', long)]
209    gateway: Option<String>,
210    #[arg(short = 'y', long)]
211    yes: bool,
212}
213
214#[derive(Debug, Args)]
215pub struct LogsArgs {
216    #[arg(short = 'n', long, default_value_t = 30)]
217    limit: usize,
218    #[arg(short, long)]
219    project: Option<String>,
220    #[arg(short, long)]
221    worktree: Option<String>,
222    #[arg(short, long)]
223    client: Option<String>,
224    #[arg(long)]
225    failed: bool,
226}
227
228#[derive(Debug, Args)]
229pub struct InitArgs {
230    path: Option<PathBuf>,
231    #[arg(short, long)]
232    mode: Option<String>,
233    #[arg(short, long)]
234    allow: Option<String>,
235    #[arg(short, long)]
236    deny: Option<String>,
237    #[arg(short, long)]
238    tools: Option<String>,
239    #[arg(short = 'y', long)]
240    yes: bool,
241    #[arg(short, long)]
242    force: bool,
243}
244
245pub async fn run(cli: Cli) -> Result<()> {
246    if matches!(&cli.command, Commands::Upgrade) {
247        return crate::upgrade::run(cli.json).await;
248    }
249    let mut config = ConfigStore::load()?;
250    if let Commands::Config { command } = &cli.command {
251        return config_command(&mut config, command, cli.json);
252    }
253    if let Commands::Gateway { command } = &cli.command {
254        return gateway_command(&mut config, command, cli.json).await;
255    }
256    if let Commands::Prompt { account } = cli.command {
257        return prompt_command(account, cli.json);
258    }
259    if let Commands::Init(args) = cli.command {
260        return init_command(args, cli.json);
261    }
262
263    let mut choice = None;
264    match &cli.command {
265        Commands::Login(value) => {
266            choice = value.gateway.clone().map(|gateway| (gateway, value.yes))
267        }
268        Commands::Connect(value) => {
269            choice = value.gateway.clone().map(|gateway| (gateway, value.yes))
270        }
271        _ => {}
272    }
273    if let Some((gateway, yes)) = choice
274        && !switch_gateway(&mut config, &gateway, yes, false, cli.json).await?
275    {
276        return Ok(());
277    }
278
279    let http = reqwest::Client::builder()
280        .user_agent(format!("exeora/{CLI_VERSION}"))
281        .build()?;
282    let gateway = config.gateway_url();
283    let auth = Arc::new(AuthManager::new(gateway.clone(), http.clone()));
284    let api = ApiClient::new(&gateway, http, auth.clone())?;
285
286    match cli.command {
287        Commands::Login(_) => login_command(&api, auth, &config).await,
288        Commands::Logout => {
289            clear_credentials()?;
290            auth.forget_access_token().await;
291            println!(
292                "Signed out of {}. The device is still registered; revoke it in the dashboard.",
293                config.gateway_url()
294            );
295            Ok(())
296        }
297        Commands::Device { command } => device_command(&mut config, &api, command, cli.json).await,
298        Commands::Project { command } => {
299            project_command(&mut config, &api, command, cli.json).await
300        }
301        Commands::Worktree { command } => {
302            worktree_command(&mut config, &api, command, cli.json).await
303        }
304        Commands::Connect(args) => connect_command(&mut config, &api, auth, args, cli.json).await,
305        Commands::Status => status_command(&config, &api, cli.json).await,
306        Commands::Logs(args) => logs_command(&api, args, cli.json).await,
307        Commands::Sync => sync_command(&mut config, &api).await,
308        Commands::Gateway { .. }
309        | Commands::Config { .. }
310        | Commands::Prompt { .. }
311        | Commands::Init(_)
312        | Commands::Upgrade => unreachable!(),
313    }
314}
315
316fn config_command(
317    config: &mut ConfigStore,
318    command: &ConfigCommand,
319    json_output: bool,
320) -> Result<()> {
321    match command {
322        ConfigCommand::Get { key } if key == "worktree-root" => {
323            let value = config.worktree_root()?;
324            if json_output {
325                emit(json!({ "key": key, "value": value, "source": config.worktree_root_source() }))
326            } else {
327                println!("{}", value.display());
328                Ok(())
329            }
330        }
331        ConfigCommand::Set { key, value } if key == "worktree-root" => {
332            let value = if value.is_absolute() {
333                value.clone()
334            } else {
335                env::current_dir()?.join(value)
336            };
337            config.data_mut().worktree_root = Some(value.clone());
338            config.save()?;
339            if json_output {
340                emit(json!({ "key": key, "value": value }))
341            } else {
342                println!("Set {key} to {}.", value.display());
343                Ok(())
344            }
345        }
346        ConfigCommand::Unset { key } if key == "worktree-root" => {
347            config.data_mut().worktree_root = None;
348            config.save()?;
349            if json_output {
350                emit(
351                    json!({ "key": key, "value": config.worktree_root()?, "source": config.worktree_root_source() }),
352                )
353            } else {
354                println!("Unset {key}.");
355                Ok(())
356            }
357        }
358        ConfigCommand::Get { key }
359        | ConfigCommand::Set { key, .. }
360        | ConfigCommand::Unset { key } => {
361            bail!("Unknown setting {key}. Available settings: worktree-root")
362        }
363    }
364}
365
366async fn worktree_command(
367    config: &mut ConfigStore,
368    api: &ApiClient,
369    command: WorktreeCommand,
370    json_output: bool,
371) -> Result<()> {
372    match command {
373        WorktreeCommand::Create {
374            branch,
375            from_ref,
376            reuse_existing_branch,
377            project,
378            name,
379            slug,
380            path,
381        } => {
382            let project = worktrees::resolve_project(config, project.as_deref())?;
383            let entry = worktrees::create(
384                config,
385                &project,
386                CreateWorktree {
387                    branch,
388                    from: from_ref,
389                    reuse_existing_branch,
390                    name,
391                    slug,
392                    path,
393                    source: None,
394                },
395            )?;
396            persist_worktree(config, api, entry, json_output).await
397        }
398        WorktreeCommand::Attach {
399            path,
400            project,
401            name,
402            slug,
403        } => {
404            let project = worktrees::resolve_project(config, project.as_deref())?;
405            let entry = worktrees::attach(config, &project, &path, name, slug)?;
406            persist_worktree(config, api, entry, json_output).await
407        }
408        WorktreeCommand::List { project, all } => {
409            let project_id = if all {
410                None
411            } else {
412                Some(worktrees::resolve_project(config, project.as_deref())?.id)
413            };
414            let entries: Vec<_> = config
415                .data()
416                .worktrees
417                .iter()
418                .filter(|entry| project_id.as_ref().is_none_or(|id| &entry.project_id == id))
419                .collect();
420            if json_output {
421                return emit(serde_json::to_value(entries)?);
422            }
423            if entries.is_empty() {
424                println!("No connected worktrees.");
425            }
426            for entry in entries {
427                let project = config
428                    .data()
429                    .projects
430                    .iter()
431                    .find(|project| project.id == entry.project_id)
432                    .map_or("removed", |project| project.slug.as_str());
433                println!(
434                    "{:<20} {:<18} {:<14} {}",
435                    entry.slug,
436                    project,
437                    format!("{:?}", entry.sync_state).to_lowercase(),
438                    entry.root.display()
439                );
440            }
441            Ok(())
442        }
443        WorktreeCommand::Detach { selector } => {
444            let mut entry = find_worktree(config, &selector)?;
445            entry.sync_state = WorktreeSyncState::Disabled;
446            config.upsert_worktree(entry.clone());
447            config.save()?;
448            match api.remove_worktree(&entry.project_id, &entry.id).await {
449                Ok(_) => {
450                    config.remove_worktree(&entry.id);
451                    config.save()?;
452                    if json_output {
453                        emit(json!({ "worktree": entry, "outcome": "detached" }))
454                    } else {
455                        println!("Detached {}. The Git worktree was not changed.", entry.slug);
456                        Ok(())
457                    }
458                }
459                Err(error) => {
460                    entry.sync_state = WorktreeSyncState::PendingDelete;
461                    config.upsert_worktree(entry.clone());
462                    config.save()?;
463                    if json_output {
464                        emit(
465                            json!({ "worktree": entry, "outcome": "pendingDelete", "warning": error.to_string() }),
466                        )
467                    } else {
468                        println!(
469                            "Detached {} locally. Gateway deletion is pending; run `exeora sync`.",
470                            entry.slug
471                        );
472                        Ok(())
473                    }
474                }
475            }
476        }
477        WorktreeCommand::Remove {
478            selector,
479            force,
480            delete_branch,
481        } => {
482            let mut entry = find_worktree(config, &selector)?;
483            let project = config
484                .data()
485                .projects
486                .iter()
487                .find(|project| project.id == entry.project_id)
488                .cloned()
489                .context("The parent project is no longer registered")?;
490            if worktrees::is_dirty(&entry)? && !force {
491                bail!(
492                    "{} has uncommitted changes. Pass --force to remove it anyway.",
493                    entry.slug
494                );
495            }
496            entry.sync_state = WorktreeSyncState::Removing;
497            config.upsert_worktree(entry.clone());
498            config.save()?;
499            if let Err(error) = worktrees::remove_git_worktree(&project, &entry, force) {
500                entry.sync_state = WorktreeSyncState::Active;
501                config.upsert_worktree(entry);
502                config.save()?;
503                return Err(error);
504            }
505            let branch = entry.branch.clone();
506            entry.sync_state = WorktreeSyncState::PendingDelete;
507            config.upsert_worktree(entry.clone());
508            config.save()?;
509            let remote_removed = api
510                .remove_worktree(&entry.project_id, &entry.id)
511                .await
512                .is_ok();
513            if remote_removed {
514                config.remove_worktree(&entry.id);
515                config.save()?;
516            }
517            if delete_branch {
518                let branch = branch
519                    .context("The worktree was detached at HEAD, so it has no branch to delete")?;
520                worktrees::delete_branch(&project, &branch)?;
521            }
522            if json_output {
523                emit(
524                    json!({ "worktree": entry, "outcome": if remote_removed { "removed" } else { "pendingDelete" }, "branchDeleted": delete_branch }),
525                )
526            } else {
527                println!(
528                    "Removed {}.{}",
529                    entry.slug,
530                    if remote_removed {
531                        ""
532                    } else {
533                        " Gateway deletion is pending; run `exeora sync`."
534                    }
535                );
536                Ok(())
537            }
538        }
539    }
540}
541
542async fn persist_worktree(
543    config: &mut ConfigStore,
544    api: &ApiClient,
545    mut entry: WorktreeEntry,
546    json_output: bool,
547) -> Result<()> {
548    config.upsert_worktree(entry.clone());
549    config.save()?;
550    let outcome = match api.put_worktree(&entry.project_id, &entry).await {
551        Ok(_) => {
552            entry.sync_state = WorktreeSyncState::Active;
553            config.upsert_worktree(entry.clone());
554            config.save()?;
555            "active"
556        }
557        Err(_) => "pendingUpsert",
558    };
559    if json_output {
560        emit(json!({ "worktree": entry, "outcome": outcome }))
561    } else {
562        println!(
563            "Connected {} at {}.{}",
564            entry.slug,
565            entry.root.display(),
566            if outcome == "active" {
567                ""
568            } else {
569                " Gateway sync is pending; run `exeora sync`."
570            }
571        );
572        Ok(())
573    }
574}
575
576fn find_worktree(config: &ConfigStore, selector: &str) -> Result<WorktreeEntry> {
577    if let Some(entry) = config
578        .data()
579        .worktrees
580        .iter()
581        .find(|entry| entry.id == selector)
582    {
583        return Ok(entry.clone());
584    }
585    let matches: Vec<_> = config
586        .data()
587        .worktrees
588        .iter()
589        .filter(|entry| entry.slug.eq_ignore_ascii_case(selector))
590        .cloned()
591        .collect();
592    match matches.as_slice() {
593        [] => Err(anyhow!("No worktree called {selector} on this machine.")),
594        [entry] => Ok(entry.clone()),
595        _ => bail!(
596            "Several projects have a worktree called {selector}. Use its wtr_ id from `exeora worktree list --all`."
597        ),
598    }
599}
600
601async fn login_command(
602    api: &ApiClient,
603    auth: Arc<AuthManager>,
604    config: &ConfigStore,
605) -> Result<()> {
606    cliclack::intro("Exeora")?;
607    let _ = auth.login_browser().await?;
608    let user = api.me().await?;
609    cliclack::log::success(format!("Signed in as {}", user.email))?;
610    if using_file_fallback() {
611        let parent = config.path().parent().unwrap_or(config.path()).display();
612        cliclack::log::warning(format!(
613            "No system keychain available, so the session is stored in a 0600 file under {parent}."
614        ))?;
615    }
616    cliclack::outro(
617        "Run `exeora connect` to bring this machine online, then `exeora project add` in a directory to serve it.",
618    )?;
619    Ok(())
620}
621
622async fn device_command(
623    config: &mut ConfigStore,
624    api: &ApiClient,
625    command: DeviceCommand,
626    json_output: bool,
627) -> Result<()> {
628    match command {
629        DeviceCommand::Register { name } => {
630            if let Some(id) = &config.data().device_id {
631                println!(
632                    "Already registered as {} ({id}).",
633                    config
634                        .data()
635                        .device_name
636                        .as_deref()
637                        .unwrap_or("this machine")
638                );
639                return Ok(());
640            }
641            let name = name.unwrap_or_else(|| {
642                hostname::get()
643                    .unwrap_or_default()
644                    .to_string_lossy()
645                    .into_owned()
646            });
647            let registered = api.register_device(&name, platform(), CLI_VERSION).await?;
648            config.data_mut().device_id = Some(registered.id.clone());
649            config.data_mut().device_name = Some(registered.name.clone());
650            config.save()?;
651            println!("Registered {} ({}).", registered.name, registered.id);
652        }
653        DeviceCommand::List => {
654            let devices = api.list_devices().await?;
655            if json_output {
656                emit(Value::Array(
657                    devices
658                        .into_iter()
659                        .map(|entry| {
660                            let this = config.data().device_id.as_deref() == Some(&entry.id);
661                            let mut value = serde_json::to_value(&entry).unwrap_or_default();
662                            value["online"] = json!(is_online(&entry));
663                            value["thisMachine"] = json!(this);
664                            value
665                        })
666                        .collect(),
667                ))?;
668            } else if devices.is_empty() {
669                println!("No devices registered yet.");
670            } else {
671                for entry in devices {
672                    println!(
673                        "{:<20} {:<9} {}{}",
674                        entry.name,
675                        if entry.revoked_at.is_some() {
676                            "revoked"
677                        } else if is_online(&entry) {
678                            "online"
679                        } else {
680                            "offline"
681                        },
682                        entry.platform,
683                        if config.data().device_id.as_deref() == Some(&entry.id) {
684                            "  (this machine)"
685                        } else {
686                            ""
687                        }
688                    );
689                }
690            }
691        }
692    }
693    Ok(())
694}
695
696async fn project_command(
697    config: &mut ConfigStore,
698    api: &ApiClient,
699    command: ProjectCommand,
700    json_output: bool,
701) -> Result<()> {
702    match command {
703        ProjectCommand::Add { path, slug } => {
704            let device = config.data().device_id.clone().ok_or_else(|| {
705                anyhow!("This machine is not registered. Run `exeora device register` first.")
706            })?;
707            let root = project_root(Some(path.unwrap_or_else(|| PathBuf::from("."))))?;
708            let name = file_name(&root)?;
709            let slug = slug.unwrap_or_else(|| slugify(&name));
710            let added = api
711                .add_project(&device, &name, &slug, &root.to_string_lossy())
712                .await?;
713            let entry = ProjectEntry {
714                id: added.id,
715                slug: added.slug.unwrap_or(slug),
716                name: added.name,
717                root,
718            };
719            config.upsert_project(entry.clone());
720            config.save()?;
721            println!("Added {}.", entry.name);
722            println!("{}", project_mcp_url(&config.gateway_url(), &entry.id)?);
723        }
724        ProjectCommand::List => {
725            if json_output {
726                emit(Value::Array(
727                    config
728                        .data()
729                        .projects
730                        .iter()
731                        .map(|entry| project_json(entry, &config.gateway_url()))
732                        .collect::<Result<_>>()?,
733                ))?;
734            } else if config.data().projects.is_empty() {
735                println!("No projects yet. Run `exeora project add` in a directory.");
736            } else {
737                for entry in &config.data().projects {
738                    println!("{:<20} {}", entry.slug, entry.root.display());
739                    println!(
740                        "{:<20} {}",
741                        "",
742                        project_mcp_url(&config.gateway_url(), &entry.id)?
743                    );
744                }
745            }
746        }
747        ProjectCommand::Remove { slug } => {
748            let entry = config
749                .data()
750                .projects
751                .iter()
752                .find(|entry| entry.slug == slug)
753                .cloned()
754                .ok_or_else(|| anyhow!("No project called {slug} on this machine."))?;
755            let _ = api.remove_project(&entry.id).await?;
756            config.remove_project(&entry.id);
757            config.save()?;
758            println!("Removed {slug}.");
759        }
760    }
761    Ok(())
762}
763
764async fn connect_command(
765    config: &mut ConfigStore,
766    api: &ApiClient,
767    auth: Arc<AuthManager>,
768    args: ConnectArgs,
769    json_output: bool,
770) -> Result<()> {
771    if args.reset {
772        config.data_mut().device_id = None;
773        config.data_mut().device_name = None;
774        config.save()?;
775    }
776    let devices = match api.list_devices().await {
777        Ok(devices) => devices,
778        Err(error) if error.to_string().contains("Not signed in") => {
779            let _ = auth.login_browser().await?;
780            api.list_devices().await?
781        }
782        Err(error) => return Err(error),
783    };
784    let device = ensure_device(config, api, devices, args.name).await?;
785    config.save()?;
786    if config.data().projects.is_empty() && !json_output {
787        println!(
788            "No projects registered yet. Run `exeora project add` in a directory to serve it."
789        );
790    }
791    connect_forever(
792        config,
793        api,
794        auth,
795        device.0,
796        config.data().projects.clone(),
797        json_output,
798    )
799    .await
800}
801
802async fn status_command(config: &ConfigStore, api: &ApiClient, json_output: bool) -> Result<()> {
803    let me = api.me().await;
804    if json_output {
805        let base = json!({
806            "gateway": config.gateway_url(), "gatewaySource": config.gateway_source(), "config": config.path(),
807            "accountMcpUrl": Url::parse(&config.gateway_url())?.join("/mcp")?,
808            "device": config.data().device_id.as_ref().map(|id| json!({ "id": id, "name": config.data().device_name })),
809        });
810        let mut value = base;
811        match me {
812            Ok(user) => {
813                let remote = api.list_projects().await?;
814                let ids: std::collections::HashSet<_> =
815                    remote.iter().map(|entry| entry.id.as_str()).collect();
816                value["signedIn"] = json!(true);
817                value["email"] = json!(user.email);
818                value["projects"] = Value::Array(
819                    config
820                        .data()
821                        .projects
822                        .iter()
823                        .map(|entry| {
824                            let mut project =
825                                project_json(entry, &config.gateway_url()).unwrap_or_default();
826                            project["knownToGateway"] = json!(ids.contains(entry.id.as_str()));
827                            project
828                        })
829                        .collect(),
830                );
831            }
832            Err(error) => {
833                value["signedIn"] = if error.to_string().contains("Not signed in") {
834                    json!(false)
835                } else {
836                    Value::Null
837                };
838                value["projects"] = json!([]);
839                if !error.to_string().contains("Not signed in") {
840                    value["error"] = json!(error.to_string());
841                }
842            }
843        }
844        return emit(value);
845    }
846    println!(
847        "Gateway   {} ({})",
848        config.gateway_url(),
849        source_description(config.gateway_source())
850    );
851    println!(
852        "One URL   {}",
853        Url::parse(&config.gateway_url())?.join("/mcp")?
854    );
855    println!("Config    {}", config.path().display());
856    println!(
857        "Device    {}",
858        config
859            .data()
860            .device_id
861            .as_ref()
862            .map(|id| format!(
863                "{} ({id})",
864                config
865                    .data()
866                    .device_name
867                    .as_deref()
868                    .unwrap_or("this machine")
869            ))
870            .unwrap_or_else(|| "not registered".to_owned())
871    );
872    match me {
873        Ok(user) => println!("Signed in {}", user.email),
874        Err(error) if error.to_string().contains("Not signed in") => {
875            println!("Signed in not signed in, run `exeora connect`");
876            return Ok(());
877        }
878        Err(_) => {
879            println!("Signed in unknown");
880            return Ok(());
881        }
882    }
883    let remote = api.list_projects().await?;
884    let ids: std::collections::HashSet<_> = remote.iter().map(|entry| entry.id.as_str()).collect();
885    println!(
886        "Projects  {}",
887        if config.data().projects.is_empty() {
888            "none"
889        } else {
890            ""
891        }
892    );
893    for entry in &config.data().projects {
894        println!(
895            "  {:<18} {}{}",
896            entry.slug,
897            entry.root.display(),
898            if ids.contains(entry.id.as_str()) {
899                ""
900            } else {
901                " (unknown to the gateway)"
902            }
903        );
904    }
905    Ok(())
906}
907
908async fn logs_command(api: &ApiClient, args: LogsArgs, json_output: bool) -> Result<()> {
909    if args.limit < 1 {
910        bail!("--limit takes a positive whole number.");
911    }
912    let (calls, projects) = tokio::try_join!(api.list_tool_calls(args.limit), api.list_projects())?;
913    let by_id: std::collections::HashMap<_, _> = projects
914        .iter()
915        .map(|entry| (entry.id.as_str(), entry))
916        .collect();
917    let rows: Vec<_> = calls
918        .into_iter()
919        .filter(|call| {
920            (!args.failed || call.status == "error")
921                && args.project.as_ref().is_none_or(|slug| {
922                    by_id
923                        .get(call.project_id.as_str())
924                        .is_some_and(|entry| entry.slug.eq_ignore_ascii_case(slug))
925                })
926                && args.worktree.as_ref().is_none_or(|selector| {
927                    call.worktree_id.as_deref() == Some(selector)
928                        || call
929                            .worktree_slug
930                            .as_deref()
931                            .is_some_and(|slug| slug.eq_ignore_ascii_case(selector))
932                        || (selector.eq_ignore_ascii_case("main") && call.worktree_id.is_none())
933                })
934                && args.client.as_ref().is_none_or(|name| {
935                    client_name(call)
936                        .to_lowercase()
937                        .contains(&name.to_lowercase())
938                })
939        })
940        .collect();
941    if json_output {
942        return emit(Value::Array(
943            rows.iter()
944                .map(|call| {
945                    let mut value = serde_json::to_value(call).unwrap_or_default();
946                    value["projectSlug"] = by_id
947                        .get(call.project_id.as_str())
948                        .map_or(Value::Null, |entry| json!(entry.slug));
949                    value
950                })
951                .collect(),
952        ));
953    }
954    if rows.is_empty() {
955        println!("Nothing matches those filters.");
956    }
957    for call in rows.iter().rev() {
958        println!(
959            "{} {:<12} {:<16} {:<16} {:<20} {}ms",
960            if call.status == "ok" { "✓" } else { "✗" },
961            call.tool,
962            by_id
963                .get(call.project_id.as_str())
964                .map_or("removed", |entry| entry.slug.as_str()),
965            call.worktree_slug.as_deref().unwrap_or("main"),
966            client_name(call),
967            call.duration_ms
968        );
969    }
970    Ok(())
971}
972
973fn init_command(args: InitArgs, json_output: bool) -> Result<()> {
974    let root = absolute(args.path.unwrap_or_else(|| PathBuf::from(".")))?;
975    let path = root.join(POLICY_FILENAME);
976    if path.exists() && !args.force {
977        bail!(
978            "{} already exists. Pass --force to replace it, or edit it by hand.",
979            path.display()
980        );
981    }
982    let mut policy = LocalCommandPolicy {
983        mode: args.mode.as_deref().map(parse_mode).transpose()?,
984        allow: args.allow.as_deref().map(split_list),
985        deny: args.deny.as_deref().map(split_list),
986        shell: None,
987        approve: None,
988        tools: args
989            .tools
990            .as_deref()
991            .map(|value| {
992                split_list(value)
993                    .into_iter()
994                    .map(|name| name.parse())
995                    .collect::<Result<Vec<_>, _>>()
996            })
997            .transpose()?,
998    };
999    if !args.yes && policy.mode.is_none() {
1000        let selected: String = cliclack::select("What may an agent do here?")
1001            .item(
1002                "allow_list".to_owned(),
1003                "Only the commands I name",
1004                "recommended",
1005            )
1006            .item("read_only".to_owned(), "Read, never change anything", "")
1007            .item("allow_all".to_owned(), "Anything the account allows", "")
1008            .interact()?;
1009        policy.mode = Some(parse_mode(&selected)?);
1010    }
1011    fs::write(&path, render_policy_toml(&policy))?;
1012    if json_output {
1013        emit(json!({ "path": path, "policy": policy, "effective": Value::Null }))?;
1014    } else {
1015        println!("Wrote {}.", path.display());
1016    }
1017    Ok(())
1018}
1019
1020fn prompt_command(account: bool, json_output: bool) -> Result<()> {
1021    let contract: Value = serde_json::from_str(include_str!("../protocol/contract.json"))?;
1022    let key = if account { "account" } else { "project" };
1023    let prompt = contract
1024        .pointer(&format!("/prompts/{key}"))
1025        .and_then(Value::as_str)
1026        .context("generated prompt is missing")?;
1027    if json_output {
1028        emit(json!({ "prompt": prompt }))
1029    } else {
1030        println!("{prompt}");
1031        Ok(())
1032    }
1033}
1034
1035async fn sync_command(config: &mut ConfigStore, api: &ApiClient) -> Result<()> {
1036    let (devices, remote) = tokio::try_join!(api.list_devices(), api.list_projects())?;
1037    let Some(stored) = config.data().device_id.clone() else {
1038        println!("This machine is not registered. Run `exeora connect` first.");
1039        return Ok(());
1040    };
1041    let Some(device) = devices.iter().find(|entry| entry.id == stored) else {
1042        let count = config.data().projects.len();
1043        config.forget_local_state();
1044        config.save()?;
1045        println!(
1046            "This machine was deleted from the dashboard. Forgot it and its {count} projects. Run `exeora connect` to register again."
1047        );
1048        return Ok(());
1049    };
1050    if device.revoked_at.is_some() {
1051        println!(
1052            "This machine ({}) was revoked from the dashboard, so it will not serve tool calls. Run `exeora connect --reset` to register it again.",
1053            device.name
1054        );
1055    }
1056    let authority: Vec<_> = remote
1057        .into_iter()
1058        .filter(|entry| entry.device_id == stored)
1059        .collect();
1060    let next: Vec<ProjectEntry> = authority
1061        .into_iter()
1062        .map(|entry| ProjectEntry {
1063            id: entry.id,
1064            slug: entry.slug,
1065            name: entry.name,
1066            root: PathBuf::from(entry.local_path),
1067        })
1068        .collect();
1069    let projects_changed = next != config.data().projects;
1070    if projects_changed {
1071        config.data_mut().projects = next;
1072        config.save()?;
1073    }
1074    let pending = config.data().worktrees.clone();
1075    let mut synced = 0usize;
1076    let mut recovered = 0usize;
1077    for mut entry in pending {
1078        match entry.sync_state {
1079            WorktreeSyncState::PendingUpsert => {
1080                if api.put_worktree(&entry.project_id, &entry).await.is_ok() {
1081                    entry.sync_state = WorktreeSyncState::Active;
1082                    config.upsert_worktree(entry);
1083                    synced += 1;
1084                }
1085            }
1086            WorktreeSyncState::PendingDelete | WorktreeSyncState::Disabled => {
1087                if api
1088                    .remove_worktree(&entry.project_id, &entry.id)
1089                    .await
1090                    .is_ok()
1091                {
1092                    config.remove_worktree(&entry.id);
1093                    synced += 1;
1094                }
1095            }
1096            WorktreeSyncState::Removing => {
1097                recovered += 1;
1098                if entry.git_root.exists() {
1099                    // The process stopped before Git removed the worktree. Make
1100                    // it routable again instead of leaving it permanently
1101                    // hidden behind the transient Removing state.
1102                    entry.sync_state = WorktreeSyncState::Active;
1103                    config.upsert_worktree(entry);
1104                } else {
1105                    // Git removal completed, but the process stopped before the
1106                    // gateway deletion. Resume that half of the operation.
1107                    entry.sync_state = WorktreeSyncState::PendingDelete;
1108                    if api
1109                        .remove_worktree(&entry.project_id, &entry.id)
1110                        .await
1111                        .is_ok()
1112                    {
1113                        config.remove_worktree(&entry.id);
1114                        synced += 1;
1115                    } else {
1116                        config.upsert_worktree(entry);
1117                    }
1118                }
1119            }
1120            WorktreeSyncState::Active => {}
1121        }
1122    }
1123    config.save()?;
1124    if projects_changed || synced > 0 || recovered > 0 {
1125        println!(
1126            "Synchronized projects and {synced} pending worktrees with the gateway; recovered {recovered} interrupted removals."
1127        );
1128    } else {
1129        println!("Already up to date.");
1130    }
1131    Ok(())
1132}
1133
1134async fn gateway_command(
1135    config: &mut ConfigStore,
1136    command: &Option<GatewayCommand>,
1137    json_output: bool,
1138) -> Result<()> {
1139    match command {
1140        None => {
1141            if json_output {
1142                emit(json!({ "gateway": config.gateway_url(), "source": config.gateway_source() }))?
1143            } else {
1144                println!(
1145                    "Gateway  {}  ({})",
1146                    config.gateway_url(),
1147                    source_description(config.gateway_source())
1148                );
1149            }
1150        }
1151        Some(GatewayCommand::Use { url, yes, force }) => {
1152            let _ = switch_gateway(config, url, *yes, *force, json_output).await?;
1153        }
1154        Some(GatewayCommand::Reset { yes, force }) => {
1155            let _ = switch_gateway(config, DEFAULT_GATEWAY, *yes, *force, json_output).await?;
1156        }
1157    }
1158    Ok(())
1159}
1160
1161async fn switch_gateway(
1162    config: &mut ConfigStore,
1163    input: &str,
1164    yes: bool,
1165    force: bool,
1166    json_output: bool,
1167) -> Result<bool> {
1168    let target = normalize_gateway(input)?;
1169    if target == config.gateway_url() {
1170        if json_output {
1171            emit(
1172                json!({ "gateway": target, "source": config.gateway_source(), "outcome": "unchanged" }),
1173            )?;
1174        } else {
1175            println!("Already using {target}.");
1176        }
1177        return Ok(true);
1178    }
1179    if !force {
1180        let http = reqwest::Client::new();
1181        let _ = discover_client(&http, &target).await?;
1182    }
1183    let signed_in = load_credentials()?.is_some();
1184    let has_state =
1185        config.data().device_name.is_some() || !config.data().projects.is_empty() || signed_in;
1186    if has_state && !yes {
1187        if json_output {
1188            bail!(
1189                "Switching to {target} would forget the current registration. Pass --yes to confirm."
1190            );
1191        }
1192        let answer = cliclack::confirm(format!("Switching to {target} forgets this machine's registration, projects and session. Switch anyway?")).initial_value(false).interact()?;
1193        if !answer {
1194            println!("Left the gateway as it was.");
1195            return Ok(false);
1196        }
1197    }
1198    clear_credentials()?;
1199    config.forget_local_state();
1200    config.data_mut().gateway_url = target.clone();
1201    config.save()?;
1202    if json_output {
1203        emit(
1204            json!({ "gateway": target, "source": config.gateway_source(), "outcome": "switched" }),
1205        )?;
1206    } else {
1207        println!("Now using {target}.");
1208    }
1209    Ok(true)
1210}
1211
1212async fn ensure_device(
1213    config: &mut ConfigStore,
1214    api: &ApiClient,
1215    devices: Vec<DeviceView>,
1216    name: Option<String>,
1217) -> Result<(String, String)> {
1218    if let Some(stored) = config.data().device_id.clone()
1219        && let Some(device) = devices.iter().find(|entry| entry.id == stored)
1220    {
1221        if device.revoked_at.is_some() {
1222            bail!(
1223                "This machine ({}) was revoked from the dashboard, so it will not serve tool calls. Run `exeora connect --reset` to register it again.",
1224                device.name
1225            );
1226        }
1227        config.data_mut().device_name = Some(device.name.clone());
1228        return Ok((device.id.clone(), device.name.clone()));
1229    }
1230    let name = name.unwrap_or_else(|| {
1231        hostname::get()
1232            .unwrap_or_default()
1233            .to_string_lossy()
1234            .into_owned()
1235    });
1236    let device = api.register_device(&name, platform(), CLI_VERSION).await?;
1237    config.data_mut().device_id = Some(device.id.clone());
1238    config.data_mut().device_name = Some(device.name.clone());
1239    println!("Registered this machine as {}.", device.name);
1240    Ok((device.id, device.name))
1241}
1242
1243fn normalize_gateway(input: &str) -> Result<String> {
1244    let trimmed = input.trim();
1245    if trimmed.is_empty() {
1246        bail!("Give the gateway's base URL, for example https://exeora.example.com.");
1247    }
1248    let explicit = trimmed.contains("://");
1249    let candidate = if explicit {
1250        trimmed.to_owned()
1251    } else {
1252        format!("https://{trimmed}")
1253    };
1254    let mut url = Url::parse(&candidate)?;
1255    if !explicit && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1")) {
1256        url.set_scheme("http")
1257            .map_err(|_| anyhow!("invalid gateway scheme"))?;
1258    }
1259    if !matches!(url.scheme(), "http" | "https") {
1260        bail!("{trimmed} is not an http or https address.");
1261    }
1262    if url.scheme() == "http" && !matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1"))
1263    {
1264        bail!(
1265            "{trimmed} is plain http, which would put your session token on the wire in the clear. Use https, or a loopback address for local development."
1266        );
1267    }
1268    if url.path() != "/" {
1269        bail!(
1270            "{trimmed} has a path. A gateway is a whole origin. Use {} instead.",
1271            url.origin().ascii_serialization()
1272        );
1273    }
1274    Ok(url.origin().ascii_serialization())
1275}
1276
1277fn project_root(path: Option<PathBuf>) -> Result<PathBuf> {
1278    let root = absolute(path.unwrap_or_else(|| PathBuf::from(".")))?;
1279    let home = env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
1280        .and_then(|home| PathBuf::from(home).canonicalize().ok());
1281    validate_project_root(root, home.as_deref())
1282}
1283
1284fn validate_project_root(root: PathBuf, home: Option<&Path>) -> Result<PathBuf> {
1285    if !root.is_dir() {
1286        bail!("{} is not a directory.", root.display());
1287    }
1288    if home.is_some_and(|home| root == home) {
1289        bail!(
1290            "That is your home directory, and a project is the boundary an agent is confined to. Run this inside the directory you want to serve."
1291        );
1292    }
1293    if root.parent().is_none() {
1294        bail!(
1295            "That is the filesystem root, and a project is the boundary an agent is confined to. Run this inside the directory you want to serve."
1296        );
1297    }
1298    Ok(root)
1299}
1300
1301fn slugify(value: &str) -> String {
1302    let slug = value
1303        .to_lowercase()
1304        .chars()
1305        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
1306        .collect::<String>()
1307        .split('-')
1308        .filter(|part| !part.is_empty())
1309        .collect::<Vec<_>>()
1310        .join("-");
1311    if slug.is_empty() {
1312        "project".to_owned()
1313    } else {
1314        slug
1315    }
1316}
1317fn absolute(path: PathBuf) -> Result<PathBuf> {
1318    if path.is_absolute() {
1319        Ok(path)
1320    } else {
1321        Ok(env::current_dir()?.join(path))
1322    }
1323    .and_then(|path| {
1324        path.canonicalize()
1325            .with_context(|| format!("Could not resolve {}", path.display()))
1326    })
1327}
1328fn file_name(path: &Path) -> Result<String> {
1329    path.file_name()
1330        .map(|name| name.to_string_lossy().into_owned())
1331        .context("The project path has no directory name")
1332}
1333fn project_mcp_url(gateway: &str, id: &str) -> Result<Url> {
1334    Ok(Url::parse(gateway)?.join(&format!("/p/{id}/mcp"))?)
1335}
1336fn project_json(entry: &ProjectEntry, gateway: &str) -> Result<Value> {
1337    Ok(
1338        json!({ "id": entry.id, "slug": entry.slug, "name": entry.name, "root": entry.root, "mcpUrl": project_mcp_url(gateway, &entry.id)? }),
1339    )
1340}
1341fn split_list(value: &str) -> Vec<String> {
1342    value
1343        .split(',')
1344        .map(str::trim)
1345        .filter(|value| !value.is_empty())
1346        .map(str::to_owned)
1347        .collect()
1348}
1349fn parse_mode(value: &str) -> Result<PolicyMode> {
1350    match value {
1351        "allow_all" => Ok(PolicyMode::AllowAll),
1352        "allow_list" => Ok(PolicyMode::AllowList),
1353        "read_only" => Ok(PolicyMode::ReadOnly),
1354        _ => bail!("invalid policy mode: {value}"),
1355    }
1356}
1357fn emit(value: Value) -> Result<()> {
1358    println!("{}", serde_json::to_string_pretty(&value)?);
1359    Ok(())
1360}
1361fn platform() -> &'static str {
1362    if cfg!(target_os = "windows") {
1363        "win32"
1364    } else if cfg!(target_os = "macos") {
1365        "darwin"
1366    } else {
1367        "linux"
1368    }
1369}
1370fn source_description(source: &str) -> &'static str {
1371    match source {
1372        "env" => "from EXEORA_GATEWAY_URL",
1373        "default" => "default",
1374        _ => "configured",
1375    }
1376}
1377fn is_online(device: &DeviceView) -> bool {
1378    device.revoked_at.is_none()
1379        && device.online.unwrap_or_else(|| {
1380            device
1381                .last_seen_at
1382                .is_some_and(|at| crate::protocol::now_ms().saturating_sub(at) < 90_000)
1383        })
1384}
1385fn client_name(call: &ToolCallView) -> String {
1386    call.client_name.clone().unwrap_or_else(|| {
1387        if call.client_id.is_some() {
1388            "unknown".to_owned()
1389        } else {
1390            "—".to_owned()
1391        }
1392    })
1393}
1394
1395#[cfg(test)]
1396mod tests {
1397    use super::validate_project_root;
1398    use std::{fs, path::PathBuf};
1399    use tempfile::tempdir;
1400
1401    #[test]
1402    fn rejects_home_root_and_files_as_project_boundaries() {
1403        let temp = tempdir().expect("temp directory");
1404        let home = temp.path().to_path_buf();
1405        assert!(validate_project_root(home.clone(), Some(&home)).is_err());
1406
1407        let file = home.join("file.txt");
1408        fs::write(&file, "not a directory").expect("fixture");
1409        assert!(validate_project_root(file, None).is_err());
1410
1411        let filesystem_root = home
1412            .ancestors()
1413            .last()
1414            .map(PathBuf::from)
1415            .expect("filesystem root");
1416        assert!(validate_project_root(filesystem_root, None).is_err());
1417    }
1418
1419    #[test]
1420    fn accepts_a_regular_project_directory() {
1421        let temp = tempdir().expect("temp directory");
1422        let project = temp.path().join("project");
1423        fs::create_dir(&project).expect("fixture");
1424        assert_eq!(
1425            validate_project_root(project.clone(), Some(temp.path())).expect("valid project"),
1426            project
1427        );
1428    }
1429}