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}