Skip to main content

exeora_cli/
cli.rs

1use crate::{
2    CLI_VERSION,
3    api::{ApiClient, DeviceView, ProjectView, ToolCallView},
4    auth::{
5        AuthManager, clear_credentials, discover_client, load_credentials, using_file_fallback,
6    },
7    config::{ConfigStore, DEFAULT_GATEWAY, ProjectEntry},
8    connection::connect_forever,
9    policy::{LocalCommandPolicy, POLICY_FILENAME, PolicyMode, render_policy_toml},
10};
11use anyhow::{Context, Result, anyhow, bail};
12use clap::{ArgAction, Args, Parser, Subcommand};
13use serde_json::{Value, json};
14use std::{
15    env, fs,
16    path::{Path, PathBuf},
17    sync::Arc,
18};
19use url::Url;
20
21#[derive(Debug, Parser)]
22#[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.")]
23pub struct Cli {
24    #[arg(short = 'v', long = "version", action = ArgAction::Version, help = "Print version")]
25    version: Option<bool>,
26    #[arg(
27        long,
28        global = true,
29        help = "Print machine-readable output instead of drawing on the terminal"
30    )]
31    pub json: bool,
32    #[command(subcommand)]
33    pub command: Commands,
34}
35
36#[derive(Debug, Subcommand)]
37pub enum Commands {
38    #[command(about = "Sign in to Exeora in your browser")]
39    Login(GatewayChoice),
40    #[command(about = "Forget the stored session on this machine")]
41    Logout,
42    #[command(about = "Show or change the Exeora this machine talks to")]
43    Gateway {
44        #[command(subcommand)]
45        command: Option<GatewayCommand>,
46    },
47    #[command(about = "Manage this machine")]
48    Device {
49        #[command(subcommand)]
50        command: DeviceCommand,
51    },
52    #[command(about = "Manage projects on this machine")]
53    Project {
54        #[command(subcommand)]
55        command: ProjectCommand,
56    },
57    #[command(about = "Serve a directory to your AI clients (signs in and registers as needed)")]
58    Connect(ConnectArgs),
59    #[command(about = "Show this machine's registration and projects")]
60    Status,
61    #[command(about = "Show recent tool calls: what ran, who asked and how it ended")]
62    Logs(LogsArgs),
63    #[command(about = "Write an exeora.toml restricting what agents may do in a directory")]
64    Init(InitArgs),
65    #[command(
66        about = "Print the Exeora coding-agent prompt, for a client that cannot fetch it itself"
67    )]
68    Prompt {
69        #[arg(short, long)]
70        account: bool,
71    },
72    #[command(about = "Reconcile this machine's registration and projects with the dashboard")]
73    Sync,
74    #[command(about = "Upgrade this native installation to the latest Exeora CLI")]
75    Upgrade,
76}
77
78#[derive(Debug, Args)]
79pub struct GatewayChoice {
80    #[arg(
81        short = 'g',
82        long,
83        help = "Sign in to this Exeora instead, and remember it"
84    )]
85    gateway: Option<String>,
86    #[arg(short = 'y', long, help = "Do not ask before switching gateway")]
87    yes: bool,
88}
89
90#[derive(Debug, Subcommand)]
91pub enum GatewayCommand {
92    #[command(about = "Talk to a different Exeora, forgetting what belongs to this one")]
93    Use {
94        url: String,
95        #[arg(short = 'y', long)]
96        yes: bool,
97        #[arg(long)]
98        force: bool,
99    },
100    #[command(about = "Go back to https://exeora.dev")]
101    Reset {
102        #[arg(short = 'y', long)]
103        yes: bool,
104        #[arg(long)]
105        force: bool,
106    },
107}
108
109#[derive(Debug, Subcommand)]
110pub enum DeviceCommand {
111    #[command(about = "Register this machine so it can serve tool calls")]
112    Register {
113        #[arg(short, long)]
114        name: Option<String>,
115    },
116    #[command(about = "List your registered machines")]
117    List,
118}
119
120#[derive(Debug, Subcommand)]
121pub enum ProjectCommand {
122    #[command(about = "Register a local directory as a project")]
123    Add {
124        path: Option<PathBuf>,
125        #[arg(short, long)]
126        slug: Option<String>,
127    },
128    #[command(about = "List projects registered on this machine")]
129    List,
130    #[command(about = "Stop serving a project from this machine")]
131    Remove { slug: String },
132}
133
134#[derive(Debug, Args)]
135pub struct ConnectArgs {
136    path: Option<PathBuf>,
137    #[arg(short, long)]
138    slug: Option<String>,
139    #[arg(short, long)]
140    name: Option<String>,
141    #[arg(long = "no-add", action = clap::ArgAction::SetFalse, default_value_t = true)]
142    add: bool,
143    #[arg(long)]
144    reset: bool,
145    #[arg(short = 'g', long)]
146    gateway: Option<String>,
147    #[arg(short = 'y', long)]
148    yes: bool,
149}
150
151#[derive(Debug, Args)]
152pub struct LogsArgs {
153    #[arg(short = 'n', long, default_value_t = 30)]
154    limit: usize,
155    #[arg(short, long)]
156    project: Option<String>,
157    #[arg(short, long)]
158    client: Option<String>,
159    #[arg(long)]
160    failed: bool,
161}
162
163#[derive(Debug, Args)]
164pub struct InitArgs {
165    path: Option<PathBuf>,
166    #[arg(short, long)]
167    mode: Option<String>,
168    #[arg(short, long)]
169    allow: Option<String>,
170    #[arg(short, long)]
171    deny: Option<String>,
172    #[arg(short, long)]
173    tools: Option<String>,
174    #[arg(short = 'y', long)]
175    yes: bool,
176    #[arg(short, long)]
177    force: bool,
178}
179
180pub async fn run(cli: Cli) -> Result<()> {
181    if matches!(&cli.command, Commands::Upgrade) {
182        return crate::upgrade::run(cli.json).await;
183    }
184    let mut config = ConfigStore::load()?;
185    if let Commands::Gateway { command } = &cli.command {
186        return gateway_command(&mut config, command, cli.json).await;
187    }
188    if let Commands::Prompt { account } = cli.command {
189        return prompt_command(account, cli.json);
190    }
191    if let Commands::Init(args) = cli.command {
192        return init_command(args, cli.json);
193    }
194
195    let mut choice = None;
196    match &cli.command {
197        Commands::Login(value) => {
198            choice = value.gateway.clone().map(|gateway| (gateway, value.yes))
199        }
200        Commands::Connect(value) => {
201            choice = value.gateway.clone().map(|gateway| (gateway, value.yes))
202        }
203        _ => {}
204    }
205    if let Some((gateway, yes)) = choice
206        && !switch_gateway(&mut config, &gateway, yes, false, cli.json).await?
207    {
208        return Ok(());
209    }
210
211    let http = reqwest::Client::builder()
212        .user_agent(format!("exeora/{CLI_VERSION}"))
213        .build()?;
214    let gateway = config.gateway_url();
215    let auth = Arc::new(AuthManager::new(gateway.clone(), http.clone()));
216    let api = ApiClient::new(&gateway, http, auth.clone())?;
217
218    match cli.command {
219        Commands::Login(_) => login_command(&api, auth, &config).await,
220        Commands::Logout => {
221            clear_credentials()?;
222            auth.forget_access_token().await;
223            println!(
224                "Signed out of {}. The device is still registered; revoke it in the dashboard.",
225                config.gateway_url()
226            );
227            Ok(())
228        }
229        Commands::Device { command } => device_command(&mut config, &api, command, cli.json).await,
230        Commands::Project { command } => {
231            project_command(&mut config, &api, command, cli.json).await
232        }
233        Commands::Connect(args) => connect_command(&mut config, &api, auth, args, cli.json).await,
234        Commands::Status => status_command(&config, &api, cli.json).await,
235        Commands::Logs(args) => logs_command(&api, args, cli.json).await,
236        Commands::Sync => sync_command(&mut config, &api).await,
237        Commands::Gateway { .. }
238        | Commands::Prompt { .. }
239        | Commands::Init(_)
240        | Commands::Upgrade => unreachable!(),
241    }
242}
243
244async fn login_command(
245    api: &ApiClient,
246    auth: Arc<AuthManager>,
247    config: &ConfigStore,
248) -> Result<()> {
249    cliclack::intro("Exeora")?;
250    let _ = auth.login_browser().await?;
251    let user = api.me().await?;
252    cliclack::log::success(format!("Signed in as {}", user.email))?;
253    if using_file_fallback() {
254        let parent = config.path().parent().unwrap_or(config.path()).display();
255        cliclack::log::warning(format!(
256            "No system keychain available, so the session is stored in a 0600 file under {parent}."
257        ))?;
258    }
259    cliclack::outro("Run `exeora connect` in a project directory.")?;
260    Ok(())
261}
262
263async fn device_command(
264    config: &mut ConfigStore,
265    api: &ApiClient,
266    command: DeviceCommand,
267    json_output: bool,
268) -> Result<()> {
269    match command {
270        DeviceCommand::Register { name } => {
271            if let Some(id) = &config.data().device_id {
272                println!(
273                    "Already registered as {} ({id}).",
274                    config
275                        .data()
276                        .device_name
277                        .as_deref()
278                        .unwrap_or("this machine")
279                );
280                return Ok(());
281            }
282            let name = name.unwrap_or_else(|| {
283                hostname::get()
284                    .unwrap_or_default()
285                    .to_string_lossy()
286                    .into_owned()
287            });
288            let registered = api.register_device(&name, platform(), CLI_VERSION).await?;
289            config.data_mut().device_id = Some(registered.id.clone());
290            config.data_mut().device_name = Some(registered.name.clone());
291            config.save()?;
292            println!("Registered {} ({}).", registered.name, registered.id);
293        }
294        DeviceCommand::List => {
295            let devices = api.list_devices().await?;
296            if json_output {
297                emit(Value::Array(
298                    devices
299                        .into_iter()
300                        .map(|entry| {
301                            let this = config.data().device_id.as_deref() == Some(&entry.id);
302                            let mut value = serde_json::to_value(&entry).unwrap_or_default();
303                            value["online"] = json!(is_online(&entry));
304                            value["thisMachine"] = json!(this);
305                            value
306                        })
307                        .collect(),
308                ))?;
309            } else if devices.is_empty() {
310                println!("No devices registered yet.");
311            } else {
312                for entry in devices {
313                    println!(
314                        "{:<20} {:<9} {}{}",
315                        entry.name,
316                        if entry.revoked_at.is_some() {
317                            "revoked"
318                        } else if is_online(&entry) {
319                            "online"
320                        } else {
321                            "offline"
322                        },
323                        entry.platform,
324                        if config.data().device_id.as_deref() == Some(&entry.id) {
325                            "  (this machine)"
326                        } else {
327                            ""
328                        }
329                    );
330                }
331            }
332        }
333    }
334    Ok(())
335}
336
337async fn project_command(
338    config: &mut ConfigStore,
339    api: &ApiClient,
340    command: ProjectCommand,
341    json_output: bool,
342) -> Result<()> {
343    match command {
344        ProjectCommand::Add { path, slug } => {
345            let device = config.data().device_id.clone().ok_or_else(|| {
346                anyhow!("This machine is not registered. Run `exeora device register` first.")
347            })?;
348            let root = absolute(path.unwrap_or_else(|| PathBuf::from(".")))?;
349            let name = file_name(&root)?;
350            let slug = slug.unwrap_or_else(|| slugify(&name));
351            let added = api
352                .add_project(&device, &name, &slug, &root.to_string_lossy())
353                .await?;
354            let entry = ProjectEntry {
355                id: added.id,
356                slug: added.slug.unwrap_or(slug),
357                name: added.name,
358                root,
359            };
360            config.upsert_project(entry.clone());
361            config.save()?;
362            println!("Added {}.", entry.name);
363            println!("{}", project_mcp_url(&config.gateway_url(), &entry.id)?);
364        }
365        ProjectCommand::List => {
366            if json_output {
367                emit(Value::Array(
368                    config
369                        .data()
370                        .projects
371                        .iter()
372                        .map(|entry| project_json(entry, &config.gateway_url()))
373                        .collect::<Result<_>>()?,
374                ))?;
375            } else if config.data().projects.is_empty() {
376                println!("No projects yet. Run `exeora connect` in one.");
377            } else {
378                for entry in &config.data().projects {
379                    println!("{:<20} {}", entry.slug, entry.root.display());
380                    println!(
381                        "{:<20} {}",
382                        "",
383                        project_mcp_url(&config.gateway_url(), &entry.id)?
384                    );
385                }
386            }
387        }
388        ProjectCommand::Remove { slug } => {
389            let entry = config
390                .data()
391                .projects
392                .iter()
393                .find(|entry| entry.slug == slug)
394                .cloned()
395                .ok_or_else(|| anyhow!("No project called {slug} on this machine."))?;
396            let _ = api.remove_project(&entry.id).await?;
397            config.remove_project(&entry.id);
398            config.save()?;
399            println!("Removed {slug}.");
400        }
401    }
402    Ok(())
403}
404
405async fn connect_command(
406    config: &mut ConfigStore,
407    api: &ApiClient,
408    auth: Arc<AuthManager>,
409    args: ConnectArgs,
410    json_output: bool,
411) -> Result<()> {
412    if args.reset {
413        config.data_mut().device_id = None;
414        config.data_mut().device_name = None;
415        config.save()?;
416    }
417    let devices = match api.list_devices().await {
418        Ok(devices) => devices,
419        Err(error) if error.to_string().contains("Not signed in") => {
420            let _ = auth.login_browser().await?;
421            api.list_devices().await?
422        }
423        Err(error) => return Err(error),
424    };
425    let device = ensure_device(config, api, devices, args.name).await?;
426    if args.add {
427        let root = project_root(args.path)?;
428        ensure_project(config, api, &device.0, root, args.slug).await?;
429    }
430    config.save()?;
431    connect_forever(
432        config,
433        api,
434        auth,
435        device.0,
436        config.data().projects.clone(),
437        json_output,
438    )
439    .await
440}
441
442async fn status_command(config: &ConfigStore, api: &ApiClient, json_output: bool) -> Result<()> {
443    let me = api.me().await;
444    if json_output {
445        let base = json!({
446            "gateway": config.gateway_url(), "gatewaySource": config.gateway_source(), "config": config.path(),
447            "accountMcpUrl": Url::parse(&config.gateway_url())?.join("/mcp")?,
448            "device": config.data().device_id.as_ref().map(|id| json!({ "id": id, "name": config.data().device_name })),
449        });
450        let mut value = base;
451        match me {
452            Ok(user) => {
453                let remote = api.list_projects().await?;
454                let ids: std::collections::HashSet<_> =
455                    remote.iter().map(|entry| entry.id.as_str()).collect();
456                value["signedIn"] = json!(true);
457                value["email"] = json!(user.email);
458                value["projects"] = Value::Array(
459                    config
460                        .data()
461                        .projects
462                        .iter()
463                        .map(|entry| {
464                            let mut project =
465                                project_json(entry, &config.gateway_url()).unwrap_or_default();
466                            project["knownToGateway"] = json!(ids.contains(entry.id.as_str()));
467                            project
468                        })
469                        .collect(),
470                );
471            }
472            Err(error) => {
473                value["signedIn"] = if error.to_string().contains("Not signed in") {
474                    json!(false)
475                } else {
476                    Value::Null
477                };
478                value["projects"] = json!([]);
479                if !error.to_string().contains("Not signed in") {
480                    value["error"] = json!(error.to_string());
481                }
482            }
483        }
484        return emit(value);
485    }
486    println!(
487        "Gateway   {} ({})",
488        config.gateway_url(),
489        source_description(config.gateway_source())
490    );
491    println!(
492        "One URL   {}",
493        Url::parse(&config.gateway_url())?.join("/mcp")?
494    );
495    println!("Config    {}", config.path().display());
496    println!(
497        "Device    {}",
498        config
499            .data()
500            .device_id
501            .as_ref()
502            .map(|id| format!(
503                "{} ({id})",
504                config
505                    .data()
506                    .device_name
507                    .as_deref()
508                    .unwrap_or("this machine")
509            ))
510            .unwrap_or_else(|| "not registered".to_owned())
511    );
512    match me {
513        Ok(user) => println!("Signed in {}", user.email),
514        Err(error) if error.to_string().contains("Not signed in") => {
515            println!("Signed in not signed in, run `exeora connect`");
516            return Ok(());
517        }
518        Err(_) => {
519            println!("Signed in unknown");
520            return Ok(());
521        }
522    }
523    let remote = api.list_projects().await?;
524    let ids: std::collections::HashSet<_> = remote.iter().map(|entry| entry.id.as_str()).collect();
525    println!(
526        "Projects  {}",
527        if config.data().projects.is_empty() {
528            "none"
529        } else {
530            ""
531        }
532    );
533    for entry in &config.data().projects {
534        println!(
535            "  {:<18} {}{}",
536            entry.slug,
537            entry.root.display(),
538            if ids.contains(entry.id.as_str()) {
539                ""
540            } else {
541                " (unknown to the gateway)"
542            }
543        );
544    }
545    Ok(())
546}
547
548async fn logs_command(api: &ApiClient, args: LogsArgs, json_output: bool) -> Result<()> {
549    if args.limit < 1 {
550        bail!("--limit takes a positive whole number.");
551    }
552    let (calls, projects) = tokio::try_join!(api.list_tool_calls(args.limit), api.list_projects())?;
553    let by_id: std::collections::HashMap<_, _> = projects
554        .iter()
555        .map(|entry| (entry.id.as_str(), entry))
556        .collect();
557    let rows: Vec<_> = calls
558        .into_iter()
559        .filter(|call| {
560            (!args.failed || call.status == "error")
561                && args.project.as_ref().is_none_or(|slug| {
562                    by_id
563                        .get(call.project_id.as_str())
564                        .is_some_and(|entry| entry.slug.eq_ignore_ascii_case(slug))
565                })
566                && args.client.as_ref().is_none_or(|name| {
567                    client_name(call)
568                        .to_lowercase()
569                        .contains(&name.to_lowercase())
570                })
571        })
572        .collect();
573    if json_output {
574        return emit(Value::Array(
575            rows.iter()
576                .map(|call| {
577                    let mut value = serde_json::to_value(call).unwrap_or_default();
578                    value["projectSlug"] = by_id
579                        .get(call.project_id.as_str())
580                        .map_or(Value::Null, |entry| json!(entry.slug));
581                    value
582                })
583                .collect(),
584        ));
585    }
586    if rows.is_empty() {
587        println!("Nothing matches those filters.");
588    }
589    for call in rows.iter().rev() {
590        println!(
591            "{} {:<12} {:<16} {:<20} {}ms",
592            if call.status == "ok" { "✓" } else { "✗" },
593            call.tool,
594            by_id
595                .get(call.project_id.as_str())
596                .map_or("removed", |entry| entry.slug.as_str()),
597            client_name(call),
598            call.duration_ms
599        );
600    }
601    Ok(())
602}
603
604fn init_command(args: InitArgs, json_output: bool) -> Result<()> {
605    let root = absolute(args.path.unwrap_or_else(|| PathBuf::from(".")))?;
606    let path = root.join(POLICY_FILENAME);
607    if path.exists() && !args.force {
608        bail!(
609            "{} already exists. Pass --force to replace it, or edit it by hand.",
610            path.display()
611        );
612    }
613    let mut policy = LocalCommandPolicy {
614        mode: args.mode.as_deref().map(parse_mode).transpose()?,
615        allow: args.allow.as_deref().map(split_list),
616        deny: args.deny.as_deref().map(split_list),
617        shell: None,
618        approve: None,
619        tools: args
620            .tools
621            .as_deref()
622            .map(|value| {
623                split_list(value)
624                    .into_iter()
625                    .map(|name| name.parse())
626                    .collect::<Result<Vec<_>, _>>()
627            })
628            .transpose()?,
629    };
630    if !args.yes && policy.mode.is_none() {
631        let selected: String = cliclack::select("What may an agent do here?")
632            .item(
633                "allow_list".to_owned(),
634                "Only the commands I name",
635                "recommended",
636            )
637            .item("read_only".to_owned(), "Read, never change anything", "")
638            .item("allow_all".to_owned(), "Anything the account allows", "")
639            .interact()?;
640        policy.mode = Some(parse_mode(&selected)?);
641    }
642    fs::write(&path, render_policy_toml(&policy))?;
643    if json_output {
644        emit(json!({ "path": path, "policy": policy, "effective": Value::Null }))?;
645    } else {
646        println!("Wrote {}.", path.display());
647    }
648    Ok(())
649}
650
651fn prompt_command(account: bool, json_output: bool) -> Result<()> {
652    let contract: Value = serde_json::from_str(include_str!("../protocol/contract.json"))?;
653    let key = if account { "account" } else { "project" };
654    let prompt = contract
655        .pointer(&format!("/prompts/{key}"))
656        .and_then(Value::as_str)
657        .context("generated prompt is missing")?;
658    if json_output {
659        emit(json!({ "prompt": prompt }))
660    } else {
661        println!("{prompt}");
662        Ok(())
663    }
664}
665
666async fn sync_command(config: &mut ConfigStore, api: &ApiClient) -> Result<()> {
667    let (devices, remote) = tokio::try_join!(api.list_devices(), api.list_projects())?;
668    let Some(stored) = config.data().device_id.clone() else {
669        println!("This machine is not registered. Run `exeora connect` first.");
670        return Ok(());
671    };
672    let Some(device) = devices.iter().find(|entry| entry.id == stored) else {
673        let count = config.data().projects.len();
674        config.forget_local_state();
675        config.save()?;
676        println!(
677            "This machine was deleted from the dashboard. Forgot it and its {count} projects. Run `exeora connect` to register again."
678        );
679        return Ok(());
680    };
681    if device.revoked_at.is_some() {
682        println!(
683            "This machine ({}) was revoked from the dashboard, so it will not serve tool calls. Run `exeora connect --reset` to register it again.",
684            device.name
685        );
686    }
687    let authority: Vec<_> = remote
688        .into_iter()
689        .filter(|entry| entry.device_id == stored)
690        .collect();
691    let next: Vec<ProjectEntry> = authority
692        .into_iter()
693        .map(|entry| ProjectEntry {
694            id: entry.id,
695            slug: entry.slug,
696            name: entry.name,
697            root: PathBuf::from(entry.local_path),
698        })
699        .collect();
700    if next == config.data().projects {
701        println!("Already up to date.");
702    } else {
703        config.data_mut().projects = next;
704        config.save()?;
705        println!("Updated local projects from the gateway.");
706    }
707    Ok(())
708}
709
710async fn gateway_command(
711    config: &mut ConfigStore,
712    command: &Option<GatewayCommand>,
713    json_output: bool,
714) -> Result<()> {
715    match command {
716        None => {
717            if json_output {
718                emit(json!({ "gateway": config.gateway_url(), "source": config.gateway_source() }))?
719            } else {
720                println!(
721                    "Gateway  {}  ({})",
722                    config.gateway_url(),
723                    source_description(config.gateway_source())
724                );
725            }
726        }
727        Some(GatewayCommand::Use { url, yes, force }) => {
728            let _ = switch_gateway(config, url, *yes, *force, json_output).await?;
729        }
730        Some(GatewayCommand::Reset { yes, force }) => {
731            let _ = switch_gateway(config, DEFAULT_GATEWAY, *yes, *force, json_output).await?;
732        }
733    }
734    Ok(())
735}
736
737async fn switch_gateway(
738    config: &mut ConfigStore,
739    input: &str,
740    yes: bool,
741    force: bool,
742    json_output: bool,
743) -> Result<bool> {
744    let target = normalize_gateway(input)?;
745    if target == config.gateway_url() {
746        if json_output {
747            emit(
748                json!({ "gateway": target, "source": config.gateway_source(), "outcome": "unchanged" }),
749            )?;
750        } else {
751            println!("Already using {target}.");
752        }
753        return Ok(true);
754    }
755    if !force {
756        let http = reqwest::Client::new();
757        let _ = discover_client(&http, &target).await?;
758    }
759    let signed_in = load_credentials()?.is_some();
760    let has_state =
761        config.data().device_name.is_some() || !config.data().projects.is_empty() || signed_in;
762    if has_state && !yes {
763        if json_output {
764            bail!(
765                "Switching to {target} would forget the current registration. Pass --yes to confirm."
766            );
767        }
768        let answer = cliclack::confirm(format!("Switching to {target} forgets this machine's registration, projects and session. Switch anyway?")).initial_value(false).interact()?;
769        if !answer {
770            println!("Left the gateway as it was.");
771            return Ok(false);
772        }
773    }
774    clear_credentials()?;
775    config.forget_local_state();
776    config.data_mut().gateway_url = target.clone();
777    config.save()?;
778    if json_output {
779        emit(
780            json!({ "gateway": target, "source": config.gateway_source(), "outcome": "switched" }),
781        )?;
782    } else {
783        println!("Now using {target}.");
784    }
785    Ok(true)
786}
787
788async fn ensure_device(
789    config: &mut ConfigStore,
790    api: &ApiClient,
791    devices: Vec<DeviceView>,
792    name: Option<String>,
793) -> Result<(String, String)> {
794    if let Some(stored) = config.data().device_id.clone()
795        && let Some(device) = devices.iter().find(|entry| entry.id == stored)
796    {
797        if device.revoked_at.is_some() {
798            bail!(
799                "This machine ({}) was revoked from the dashboard, so it will not serve tool calls. Run `exeora connect --reset` to register it again.",
800                device.name
801            );
802        }
803        config.data_mut().device_name = Some(device.name.clone());
804        return Ok((device.id.clone(), device.name.clone()));
805    }
806    let name = name.unwrap_or_else(|| {
807        hostname::get()
808            .unwrap_or_default()
809            .to_string_lossy()
810            .into_owned()
811    });
812    let device = api.register_device(&name, platform(), CLI_VERSION).await?;
813    config.data_mut().device_id = Some(device.id.clone());
814    config.data_mut().device_name = Some(device.name.clone());
815    println!("Registered this machine as {}.", device.name);
816    Ok((device.id, device.name))
817}
818
819async fn ensure_project(
820    config: &mut ConfigStore,
821    api: &ApiClient,
822    device_id: &str,
823    root: PathBuf,
824    requested: Option<String>,
825) -> Result<ProjectEntry> {
826    let remote = api.list_projects().await?;
827    if let Some(local) = config
828        .data()
829        .projects
830        .iter()
831        .find(|entry| entry.root == root)
832        && remote.iter().any(|entry| {
833            entry.id == local.id
834                && entry.device_id == device_id
835                && entry.local_path == root.to_string_lossy()
836        })
837    {
838        return Ok(local.clone());
839    }
840    let name = file_name(&root)?;
841    let local = config
842        .data()
843        .projects
844        .iter()
845        .find(|entry| entry.root == root);
846    let slug = requested
847        .or_else(|| local.map(|entry| entry.slug.clone()))
848        .unwrap_or_else(|| unique_slug(&name, &root, &remote));
849    let added = api
850        .add_project(device_id, &name, &slug, &root.to_string_lossy())
851        .await?;
852    let entry = ProjectEntry {
853        id: added.id,
854        slug: added.slug.unwrap_or(slug),
855        name: added.name,
856        root,
857    };
858    config.upsert_project(entry.clone());
859    println!("Serving {} from {}.", entry.name, entry.root.display());
860    Ok(entry)
861}
862
863fn normalize_gateway(input: &str) -> Result<String> {
864    let trimmed = input.trim();
865    if trimmed.is_empty() {
866        bail!("Give the gateway's base URL, for example https://exeora.example.com.");
867    }
868    let explicit = trimmed.contains("://");
869    let candidate = if explicit {
870        trimmed.to_owned()
871    } else {
872        format!("https://{trimmed}")
873    };
874    let mut url = Url::parse(&candidate)?;
875    if !explicit && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1")) {
876        url.set_scheme("http")
877            .map_err(|_| anyhow!("invalid gateway scheme"))?;
878    }
879    if !matches!(url.scheme(), "http" | "https") {
880        bail!("{trimmed} is not an http or https address.");
881    }
882    if url.scheme() == "http" && !matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1"))
883    {
884        bail!(
885            "{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."
886        );
887    }
888    if url.path() != "/" {
889        bail!(
890            "{trimmed} has a path. A gateway is a whole origin. Use {} instead.",
891            url.origin().ascii_serialization()
892        );
893    }
894    Ok(url.origin().ascii_serialization())
895}
896
897fn project_root(path: Option<PathBuf>) -> Result<PathBuf> {
898    let root = absolute(path.unwrap_or_else(|| PathBuf::from(".")))?;
899    if env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
900        .is_some_and(|home| root.as_os_str() == home)
901    {
902        bail!(
903            "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."
904        );
905    }
906    if root.parent().is_none() {
907        bail!(
908            "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."
909        );
910    }
911    Ok(root)
912}
913
914fn unique_slug(name: &str, root: &Path, remote: &[ProjectView]) -> String {
915    let base = slugify(name);
916    let taken: std::collections::HashSet<_> = remote
917        .iter()
918        .filter(|entry| Path::new(&entry.local_path) != root)
919        .map(|entry| entry.slug.as_str())
920        .collect();
921    if !taken.contains(base.as_str()) {
922        return base;
923    }
924    (2..100)
925        .map(|suffix| format!("{base}-{suffix}"))
926        .find(|candidate| !taken.contains(candidate.as_str()))
927        .unwrap_or_else(|| format!("{base}-{}", crate::protocol::now_ms()))
928}
929fn slugify(value: &str) -> String {
930    let slug = value
931        .to_lowercase()
932        .chars()
933        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
934        .collect::<String>()
935        .split('-')
936        .filter(|part| !part.is_empty())
937        .collect::<Vec<_>>()
938        .join("-");
939    if slug.is_empty() {
940        "project".to_owned()
941    } else {
942        slug
943    }
944}
945fn absolute(path: PathBuf) -> Result<PathBuf> {
946    if path.is_absolute() {
947        Ok(path)
948    } else {
949        Ok(env::current_dir()?.join(path))
950    }
951    .and_then(|path| {
952        path.canonicalize()
953            .with_context(|| format!("Could not resolve {}", path.display()))
954    })
955}
956fn file_name(path: &Path) -> Result<String> {
957    path.file_name()
958        .map(|name| name.to_string_lossy().into_owned())
959        .context("The project path has no directory name")
960}
961fn project_mcp_url(gateway: &str, id: &str) -> Result<Url> {
962    Ok(Url::parse(gateway)?.join(&format!("/p/{id}/mcp"))?)
963}
964fn project_json(entry: &ProjectEntry, gateway: &str) -> Result<Value> {
965    Ok(
966        json!({ "id": entry.id, "slug": entry.slug, "name": entry.name, "root": entry.root, "mcpUrl": project_mcp_url(gateway, &entry.id)? }),
967    )
968}
969fn split_list(value: &str) -> Vec<String> {
970    value
971        .split(',')
972        .map(str::trim)
973        .filter(|value| !value.is_empty())
974        .map(str::to_owned)
975        .collect()
976}
977fn parse_mode(value: &str) -> Result<PolicyMode> {
978    match value {
979        "allow_all" => Ok(PolicyMode::AllowAll),
980        "allow_list" => Ok(PolicyMode::AllowList),
981        "read_only" => Ok(PolicyMode::ReadOnly),
982        _ => bail!("invalid policy mode: {value}"),
983    }
984}
985fn emit(value: Value) -> Result<()> {
986    println!("{}", serde_json::to_string_pretty(&value)?);
987    Ok(())
988}
989fn platform() -> &'static str {
990    if cfg!(target_os = "windows") {
991        "win32"
992    } else if cfg!(target_os = "macos") {
993        "darwin"
994    } else {
995        "linux"
996    }
997}
998fn source_description(source: &str) -> &'static str {
999    match source {
1000        "env" => "from EXEORA_GATEWAY_URL",
1001        "default" => "default",
1002        _ => "configured",
1003    }
1004}
1005fn is_online(device: &DeviceView) -> bool {
1006    device.revoked_at.is_none()
1007        && device.online.unwrap_or_else(|| {
1008            device
1009                .last_seen_at
1010                .is_some_and(|at| crate::protocol::now_ms().saturating_sub(at) < 90_000)
1011        })
1012}
1013fn client_name(call: &ToolCallView) -> String {
1014    call.client_name.clone().unwrap_or_else(|| {
1015        if call.client_id.is_some() {
1016            "unknown".to_owned()
1017        } else {
1018            "—".to_owned()
1019        }
1020    })
1021}