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 entry.sync_state = WorktreeSyncState::Active;
1103 config.upsert_worktree(entry);
1104 } else {
1105 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}