1use std::path::PathBuf;
2
3use anyhow::{Context, Result, bail};
4use clap::{Args, Parser, Subcommand};
5use tracing_subscriber::{EnvFilter, fmt};
6
7use crate::{
8 auth::{
9 oauth::{
10 OAuthLoginConfig, OAuthTokenNamespace, delete_provider_tokens, login as oauth_login,
11 },
12 provider::ProviderAuthConfig,
13 },
14 chat::{ChatOptions, run_chat},
15 config::{AppConfig, AppRegistry, ConfigPaths, app_name_from_dir, load_secret},
16 doctor::{DoctorRunArgs, run_doctor, run_doctor_models},
17 history::{HistoryCommand, run_history_command},
18 init::run_init,
19 mcp_server::{McpServeOptions, run_mcp_server},
20 plugins,
21 run::{RunOptions, run_once},
22 serve::{ServeOptions, run_server},
23 sync::{SyncRequest, run_sync},
24};
25
26#[derive(Debug, Parser)]
27#[command(
28 name = "appctl",
29 version,
30 about = "One command. Any app. Full AI control."
31)]
32pub struct Cli {
33 #[command(subcommand)]
34 pub command: Command,
35
36 #[arg(long, global = true, default_value = ".appctl")]
37 pub app_dir: PathBuf,
38
39 #[arg(long, global = true, default_value = "info")]
40 pub log_level: String,
41}
42
43#[derive(Debug, Subcommand)]
44#[allow(clippy::large_enum_variant)]
45pub enum Command {
46 Init,
48 Sync(SyncArgs),
50 Chat(ChatArgs),
52 Run(RunArgs),
54 Doctor(DoctorArgsCli),
56 History(HistoryArgs),
58 Serve(ServeArgs),
60 Config(ConfigArgs),
62 Plugin(PluginArgs),
64 Auth(AuthArgs),
66 Mcp(McpArgs),
68 App(AppArgs),
70}
71
72#[derive(Debug, Args)]
73pub struct DoctorArgsCli {
74 #[arg(long)]
76 pub write: bool,
77 #[arg(long, default_value_t = 10)]
78 pub timeout_secs: u64,
79 #[command(subcommand)]
80 pub command: Option<DoctorSubcommand>,
81}
82
83#[derive(Debug, Subcommand)]
84pub enum DoctorSubcommand {
85 Models {
87 #[arg(long)]
89 provider: Option<String>,
90 },
91}
92
93#[derive(Debug, Args)]
94pub struct AppArgs {
95 #[command(subcommand)]
96 pub command: AppSubcommand,
97}
98
99#[derive(Debug, Subcommand)]
100pub enum AppSubcommand {
101 Add {
103 name: Option<String>,
105 #[arg(long)]
107 path: Option<PathBuf>,
108 },
109 List,
111 Use { name: String },
113 Remove { name: String },
115}
116
117#[derive(Debug, Args)]
118pub struct AuthArgs {
119 #[command(subcommand)]
120 pub command: AuthSubcommand,
121}
122
123#[derive(Debug)]
124struct ProviderLoginRequest {
125 profile: Option<String>,
126 value: Option<String>,
127 client_id: Option<String>,
128 client_secret: Option<String>,
129 auth_url: Option<String>,
130 token_url: Option<String>,
131 scope: Vec<String>,
132 redirect_port: u16,
133}
134
135#[derive(Debug, Subcommand)]
136pub enum AuthSubcommand {
137 Login {
139 provider: String,
140 #[arg(long)]
141 client_id: Option<String>,
142 #[arg(long)]
143 client_secret: Option<String>,
144 #[arg(long)]
145 auth_url: Option<String>,
146 #[arg(long)]
147 token_url: Option<String>,
148 #[arg(long)]
149 scope: Vec<String>,
150 #[arg(long, default_value_t = 8421)]
151 redirect_port: u16,
152 },
153 Status { provider: String },
155 Target {
156 #[command(subcommand)]
157 command: TargetAuthSubcommand,
158 },
159 Provider {
160 #[command(subcommand)]
161 command: ProviderAuthSubcommand,
162 },
163}
164
165#[derive(Debug, Subcommand)]
166pub enum TargetAuthSubcommand {
167 Login {
168 provider: String,
169 #[arg(long)]
170 client_id: Option<String>,
171 #[arg(long)]
172 client_secret: Option<String>,
173 #[arg(long)]
174 auth_url: Option<String>,
175 #[arg(long)]
176 token_url: Option<String>,
177 #[arg(long)]
178 scope: Vec<String>,
179 #[arg(long, default_value_t = 8421)]
180 redirect_port: u16,
181 },
182 Status {
183 provider: String,
184 },
185}
186
187#[derive(Debug, Subcommand)]
188pub enum ProviderAuthSubcommand {
189 Login {
190 provider: String,
191 #[arg(long)]
192 profile: Option<String>,
193 #[arg(long)]
194 value: Option<String>,
195 #[arg(long)]
196 client_id: Option<String>,
197 #[arg(long)]
198 client_secret: Option<String>,
199 #[arg(long)]
200 auth_url: Option<String>,
201 #[arg(long)]
202 token_url: Option<String>,
203 #[arg(long)]
204 scope: Vec<String>,
205 #[arg(long, default_value_t = 8421)]
206 redirect_port: u16,
207 },
208 Status {
209 provider: Option<String>,
210 },
211 Logout {
212 provider: String,
213 },
214 List,
215}
216
217#[derive(Debug, Args)]
218pub struct SyncArgs {
219 #[arg(long)]
220 pub openapi: Option<String>,
221 #[arg(long)]
222 pub django: Option<PathBuf>,
223 #[arg(long)]
224 pub db: Option<String>,
225 #[arg(long)]
226 pub url: Option<String>,
227 #[arg(long)]
228 pub mcp: Option<String>,
229 #[arg(long)]
230 pub rails: Option<PathBuf>,
231 #[arg(long)]
232 pub laravel: Option<PathBuf>,
233 #[arg(long)]
234 pub aspnet: Option<PathBuf>,
235 #[arg(long)]
236 pub strapi: Option<PathBuf>,
237 #[arg(long)]
238 pub supabase: Option<String>,
239 #[arg(long)]
240 pub supabase_anon_ref: Option<String>,
241 #[arg(long)]
243 pub plugin: Option<String>,
244 #[arg(long)]
245 pub auth_header: Option<String>,
246 #[arg(long)]
247 pub base_url: Option<String>,
248 #[arg(long)]
249 pub force: bool,
250 #[arg(long)]
251 pub login_url: Option<String>,
252 #[arg(long)]
253 pub login_user: Option<String>,
254 #[arg(long)]
255 pub login_password: Option<String>,
256 #[arg(long)]
257 pub login_form_selector: Option<String>,
258}
259
260#[derive(Debug, Args)]
261pub struct ChatArgs {
262 #[arg(long)]
263 pub provider: Option<String>,
264 #[arg(long)]
265 pub model: Option<String>,
266 #[arg(long)]
267 pub read_only: bool,
268 #[arg(long)]
269 pub dry_run: bool,
270 #[arg(long)]
271 pub confirm: bool,
272 #[arg(long)]
274 pub strict: bool,
275}
276
277#[derive(Debug, Args)]
278pub struct RunArgs {
279 pub prompt: String,
280 #[arg(long)]
281 pub provider: Option<String>,
282 #[arg(long)]
283 pub model: Option<String>,
284 #[arg(long)]
285 pub read_only: bool,
286 #[arg(long)]
287 pub dry_run: bool,
288 #[arg(long)]
289 pub confirm: bool,
290 #[arg(long)]
291 pub strict: bool,
292}
293
294#[derive(Debug, Args)]
295pub struct HistoryArgs {
296 #[arg(long, default_value_t = 20)]
297 pub last: usize,
298 #[arg(long)]
299 pub undo: Option<i64>,
300}
301
302#[derive(Debug, Args)]
303pub struct ServeArgs {
304 #[arg(long, default_value_t = 4242)]
305 pub port: u16,
306 #[arg(long, default_value = "127.0.0.1")]
307 pub bind: String,
308 #[arg(long)]
309 pub token: Option<String>,
310 #[arg(long)]
311 pub provider: Option<String>,
312 #[arg(long)]
313 pub model: Option<String>,
314 #[arg(long)]
315 pub strict: bool,
316 #[arg(long)]
317 pub read_only: bool,
318 #[arg(long)]
319 pub dry_run: bool,
320 #[arg(long, default_value_t = true)]
322 pub confirm: bool,
323}
324
325#[derive(Debug, Args)]
326pub struct ConfigArgs {
327 #[command(subcommand)]
328 pub command: ConfigSubcommand,
329}
330
331#[derive(Debug, Subcommand)]
332pub enum ConfigSubcommand {
333 Init,
334 Show,
335 ProviderSample {
336 #[arg(long)]
337 preset: Option<String>,
338 },
339 SetSecret {
341 name: String,
342 #[arg(long)]
344 value: Option<String>,
345 },
346}
347
348#[derive(Debug, Args)]
349pub struct PluginArgs {
350 #[command(subcommand)]
351 pub command: PluginSubcommand,
352}
353
354#[derive(Debug, Args)]
355pub struct McpArgs {
356 #[command(subcommand)]
357 pub command: McpSubcommand,
358}
359
360#[derive(Debug, Subcommand)]
361pub enum McpSubcommand {
362 Serve {
363 #[arg(long)]
364 read_only: bool,
365 #[arg(long)]
366 dry_run: bool,
367 #[arg(long)]
368 strict: bool,
369 #[arg(long, default_value_t = true)]
370 confirm: bool,
371 },
372}
373
374#[derive(Debug, Subcommand)]
375pub enum PluginSubcommand {
376 List,
377 Install { name: String },
378}
379
380impl Cli {
381 pub async fn run(self) -> Result<()> {
382 init_tracing(&self.log_level)?;
383
384 let paths = ConfigPaths::new(self.app_dir.clone());
385
386 match self.command {
387 Command::Init => {
388 run_init(&paths).await?;
389 }
390 Command::App(args) => {
391 run_app_command(&paths, args.command)?;
392 }
393 Command::Sync(args) => {
394 if let Some(name) = args.plugin.as_deref() {
395 run_dynamic_sync(paths, name, args.base_url.as_deref())?;
396 } else {
397 let request = SyncRequest {
398 openapi: args.openapi,
399 django: args.django,
400 db: args.db,
401 url: args.url,
402 mcp: args.mcp,
403 rails: args.rails,
404 laravel: args.laravel,
405 aspnet: args.aspnet,
406 strapi: args.strapi,
407 supabase: args.supabase,
408 supabase_anon_ref: args.supabase_anon_ref,
409 auth_header: args.auth_header,
410 base_url: args.base_url,
411 force: args.force,
412 login_url: args.login_url,
413 login_user: args.login_user,
414 login_password: args.login_password,
415 login_form_selector: args.login_form_selector,
416 };
417 run_sync(paths, request).await?;
418 }
419 }
420 Command::Chat(args) => {
421 let config = AppConfig::load_or_init(&paths)?;
422 run_chat(
423 &paths,
424 &config,
425 "app",
426 ChatOptions {
427 provider: args.provider,
428 model: args.model,
429 read_only: args.read_only,
430 dry_run: args.dry_run,
431 confirm: args.confirm,
432 strict: args.strict,
433 },
434 )
435 .await?;
436 }
437 Command::Run(args) => {
438 let config = AppConfig::load_or_init(&paths)?;
439 run_once(
440 &paths,
441 &config,
442 "app",
443 RunOptions {
444 prompt: args.prompt,
445 provider: args.provider,
446 model: args.model,
447 read_only: args.read_only,
448 dry_run: args.dry_run,
449 confirm: args.confirm,
450 strict: args.strict,
451 },
452 )
453 .await?;
454 }
455 Command::Doctor(args) => match args.command {
456 Some(DoctorSubcommand::Models { provider }) => {
457 let config = AppConfig::load_or_init(&paths)?;
458 run_doctor_models(&paths, &config, provider.as_deref()).await?;
459 }
460 None => {
461 run_doctor(
462 &paths,
463 DoctorRunArgs {
464 write: args.write,
465 timeout_secs: args.timeout_secs,
466 },
467 )
468 .await?;
469 }
470 },
471 Command::History(args) => {
472 run_history_command(
473 &paths,
474 HistoryCommand {
475 last: args.last,
476 undo: args.undo,
477 },
478 )
479 .await?;
480 }
481 Command::Serve(args) => {
482 let config = AppConfig::load_or_init(&paths)?;
483 run_server(
484 "app".to_string(),
485 paths,
486 config,
487 ServeOptions {
488 port: args.port,
489 bind: args.bind,
490 token: args.token,
491 provider: args.provider,
492 model: args.model,
493 strict: args.strict,
494 read_only: args.read_only,
495 dry_run: args.dry_run,
496 confirm: args.confirm,
497 },
498 )
499 .await?;
500 }
501 Command::Config(args) => match args.command {
502 ConfigSubcommand::Init => {
503 run_init(&paths).await?;
504 }
505 ConfigSubcommand::Show => {
506 let config = AppConfig::load_or_init(&paths)?;
507 println!("{}", toml::to_string_pretty(&config)?);
508 }
509 ConfigSubcommand::ProviderSample { preset } => {
510 println!("{}", provider_sample_toml(preset.as_deref())?);
511 }
512 ConfigSubcommand::SetSecret { name, value } => {
513 let v = match value {
514 Some(s) => s,
515 None => dialoguer::Password::new()
516 .with_prompt(format!("Enter secret `{name}`"))
517 .interact()?,
518 };
519 crate::config::save_secret(&name, &v)?;
520 println!("stored secret '{}' in keychain", name);
521 }
522 },
523 Command::Plugin(args) => match args.command {
524 PluginSubcommand::List => {
525 println!(
526 "Built-in sync plugins: openapi, django, db, url, mcp, rails, laravel, aspnet, strapi, supabase"
527 );
528 let dir = plugins::plugin_dir()?;
529 println!("Dynamic plugin directory: {}", dir.display());
530 match plugins::discover() {
531 Ok(found) if found.is_empty() => {
532 println!("(no dynamic plugins installed)");
533 }
534 Ok(found) => {
535 println!("Dynamic plugins:");
536 for plugin in found {
537 println!(
538 " - {} v{} ({})",
539 plugin.name,
540 plugin.version,
541 plugin.source_path.display()
542 );
543 }
544 }
545 Err(err) => tracing::warn!("failed to enumerate plugins: {err:#}"),
546 }
547 }
548 PluginSubcommand::Install { name } => {
549 install_plugin(&name)?;
550 }
551 },
552 Command::Auth(args) => match args.command {
553 AuthSubcommand::Login {
554 provider,
555 client_id,
556 client_secret,
557 auth_url,
558 token_url,
559 scope,
560 redirect_port,
561 } => {
562 login_target_auth(
563 &provider,
564 client_id,
565 client_secret,
566 auth_url,
567 token_url,
568 scope,
569 redirect_port,
570 )
571 .await?;
572 }
573 AuthSubcommand::Status { provider } => {
574 print_target_auth_status(&provider);
575 }
576 AuthSubcommand::Target { command } => match command {
577 TargetAuthSubcommand::Login {
578 provider,
579 client_id,
580 client_secret,
581 auth_url,
582 token_url,
583 scope,
584 redirect_port,
585 } => {
586 login_target_auth(
587 &provider,
588 client_id,
589 client_secret,
590 auth_url,
591 token_url,
592 scope,
593 redirect_port,
594 )
595 .await?;
596 }
597 TargetAuthSubcommand::Status { provider } => {
598 print_target_auth_status(&provider);
599 }
600 },
601 AuthSubcommand::Provider { command } => match command {
602 ProviderAuthSubcommand::Login {
603 provider,
604 profile,
605 value,
606 client_id,
607 client_secret,
608 auth_url,
609 token_url,
610 scope,
611 redirect_port,
612 } => {
613 let config = AppConfig::load_or_init(&paths)?;
614 login_provider_auth(
615 &config,
616 &provider,
617 ProviderLoginRequest {
618 profile,
619 value,
620 client_id,
621 client_secret,
622 auth_url,
623 token_url,
624 scope,
625 redirect_port,
626 },
627 )
628 .await?;
629 }
630 ProviderAuthSubcommand::Status { provider } => {
631 let config = AppConfig::load_or_init(&paths)?;
632 print_provider_auth_status(&paths, &config, provider.as_deref())?;
633 }
634 ProviderAuthSubcommand::Logout { provider } => {
635 let config = AppConfig::load_or_init(&paths)?;
636 logout_provider_auth(&config, &provider)?;
637 }
638 ProviderAuthSubcommand::List => {
639 let config = AppConfig::load_or_init(&paths)?;
640 print_provider_auth_status(&paths, &config, None)?;
641 }
642 },
643 },
644 Command::Mcp(args) => match args.command {
645 McpSubcommand::Serve {
646 read_only,
647 dry_run,
648 strict,
649 confirm,
650 } => {
651 run_mcp_server(
652 paths,
653 McpServeOptions {
654 read_only,
655 dry_run,
656 strict,
657 confirm,
658 },
659 )
660 .await?;
661 }
662 },
663 }
664
665 Ok(())
666 }
667}
668
669fn run_dynamic_sync(paths: ConfigPaths, name: &str, base_url: Option<&str>) -> Result<()> {
670 paths.ensure()?;
671 let plugins = plugins::discover()?;
672 let plugin = plugins
673 .into_iter()
674 .find(|p| p.name == name)
675 .with_context(|| {
676 format!(
677 "no dynamic plugin named '{}' installed in {:?}",
678 name,
679 plugins::plugin_dir().ok()
680 )
681 })?;
682 let input = appctl_plugin_sdk::SyncInput {
683 base_url: base_url.map(|s| s.to_string()),
684 ..Default::default()
685 };
686 let mut schema = plugin.introspect(&input)?;
687 if let Some(b) = base_url {
688 schema.base_url = Some(b.to_string());
689 }
690 let tools = crate::tools::schema_to_tools(&schema);
691 crate::config::write_json(&paths.schema, &schema)?;
692 crate::config::write_json(&paths.tools, &tools)?;
693 println!(
694 "Synced via dynamic plugin '{}': {} resources, {} tools",
695 plugin.name,
696 schema.resources.len(),
697 tools.len()
698 );
699 Ok(())
700}
701
702fn install_plugin(source: &str) -> Result<()> {
703 use std::process::Command;
704
705 let dir = plugins::plugin_dir()?;
706 std::fs::create_dir_all(&dir)?;
707
708 let src_path = std::path::PathBuf::from(source);
710 if src_path.exists() && src_path.is_file() {
711 let dest = dir.join(src_path.file_name().context("no file name")?);
712 std::fs::copy(&src_path, &dest)?;
713 println!("Installed {} -> {}", src_path.display(), dest.display());
714 return Ok(());
715 }
716
717 let staging = tempfile::TempDir::new()?;
719 let target_dir = staging.path().join("target");
720 let status = if source.starts_with("http://")
721 || source.starts_with("https://")
722 || source.starts_with("git@")
723 {
724 Command::new("cargo")
725 .args([
726 "install",
727 "--git",
728 source,
729 "--target-dir",
730 target_dir.to_str().unwrap(),
731 "--force",
732 ])
733 .status()
734 } else {
735 Command::new("cargo")
736 .args([
737 "install",
738 source,
739 "--target-dir",
740 target_dir.to_str().unwrap(),
741 "--force",
742 ])
743 .status()
744 }
745 .context("failed to spawn cargo install")?;
746 if !status.success() {
747 bail!(
748 "cargo install for '{}' failed; build it manually as a cdylib and drop the library into {}",
749 source,
750 dir.display()
751 );
752 }
753
754 let mut installed = 0;
756 for entry in walkdir::WalkDir::new(&target_dir) {
757 let Ok(entry) = entry else { continue };
758 let path = entry.path();
759 let ext = path
760 .extension()
761 .and_then(|e| e.to_str())
762 .unwrap_or_default();
763 if matches!(ext, "dylib" | "so" | "dll")
764 && let Some(name) = path.file_name()
765 {
766 let dest = dir.join(name);
767 std::fs::copy(path, &dest)?;
768 println!("Installed {} -> {}", path.display(), dest.display());
769 installed += 1;
770 }
771 }
772 if installed == 0 {
773 bail!(
774 "no cdylib artifacts produced; ensure the plugin's Cargo.toml has `crate-type = [\"cdylib\"]`"
775 );
776 }
777 Ok(())
778}
779
780async fn login_target_auth(
781 provider: &str,
782 client_id: Option<String>,
783 client_secret: Option<String>,
784 auth_url: Option<String>,
785 token_url: Option<String>,
786 scope: Vec<String>,
787 redirect_port: u16,
788) -> Result<()> {
789 let client_id = client_id
790 .or_else(|| std::env::var(format!("{provider}_CLIENT_ID")).ok())
791 .context("--client-id is required (or set <provider>_CLIENT_ID)")?;
792 let auth_url =
793 auth_url.context("--auth-url is required (the provider's authorization endpoint)")?;
794 let token_url = token_url.context("--token-url is required (the provider's token endpoint)")?;
795 let config = OAuthLoginConfig {
796 provider: provider.to_string(),
797 storage_key: provider.to_string(),
798 namespace: OAuthTokenNamespace::Target,
799 client_id,
800 client_secret: client_secret
801 .or_else(|| std::env::var(format!("{provider}_CLIENT_SECRET")).ok()),
802 auth_url,
803 token_url,
804 scopes: scope,
805 redirect_port,
806 };
807 let tokens = oauth_login(config).await?;
808 println!(
809 "Logged in for target provider '{}'. Access token stored in keychain ({} scopes).",
810 provider,
811 tokens.scopes.len()
812 );
813 Ok(())
814}
815
816async fn login_provider_auth(
817 config: &AppConfig,
818 provider_name: &str,
819 request: ProviderLoginRequest,
820) -> Result<()> {
821 let ProviderLoginRequest {
822 profile,
823 value,
824 client_id,
825 client_secret,
826 auth_url,
827 token_url,
828 scope,
829 redirect_port,
830 } = request;
831 let provider = config
832 .providers
833 .iter()
834 .find(|provider| provider.name == provider_name);
835
836 let auth = provider
837 .and_then(|provider| provider.auth.clone())
838 .or_else(|| provider_auth_preset(provider_name));
839
840 match auth {
841 Some(ProviderAuthConfig::None) => {
842 println!(
843 "provider '{}' does not require credentials; nothing to log in",
844 provider_name
845 );
846 Ok(())
847 }
848 Some(ProviderAuthConfig::ApiKey { secret_ref, .. }) => {
849 let secret = match value {
850 Some(value) => value,
851 None => dialoguer::Password::new()
852 .with_prompt(format!("Enter API key for `{provider_name}`"))
853 .interact()?,
854 };
855 crate::config::save_secret(&secret_ref, &secret)?;
856 println!(
857 "stored provider secret for '{}' in keychain under '{}'",
858 provider_name, secret_ref
859 );
860 Ok(())
861 }
862 Some(ProviderAuthConfig::OAuth2 {
863 profile: configured_profile,
864 scopes,
865 client_id_ref,
866 client_secret_ref,
867 auth_url: configured_auth_url,
868 token_url: configured_token_url,
869 }) => {
870 let storage_key = profile.unwrap_or(configured_profile);
871 let requested_scopes = if scope.is_empty() { scopes } else { scope };
872 let client_id = client_id
873 .or_else(|| client_id_ref.as_deref().and_then(|name| std::env::var(name).ok()))
874 .or_else(|| client_id_ref.as_deref().and_then(|name| load_secret(name).ok()))
875 .or_else(|| {
876 if provider_name == "gemini" {
877 std::env::var("GOOGLE_CLIENT_ID")
878 .ok()
879 .or_else(|| load_secret("GOOGLE_CLIENT_ID").ok())
880 } else {
881 None
882 }
883 })
884 .context("provider auth is missing a client id; set it in the auth block, set GOOGLE_CLIENT_ID, or pass --client-id")?;
885 let client_secret = client_secret
886 .or_else(|| {
887 client_secret_ref
888 .as_deref()
889 .and_then(|name| std::env::var(name).ok())
890 })
891 .or_else(|| {
892 client_secret_ref
893 .as_deref()
894 .and_then(|name| load_secret(name).ok())
895 })
896 .or_else(|| {
897 if provider_name == "gemini" {
898 std::env::var("GOOGLE_CLIENT_SECRET")
899 .ok()
900 .or_else(|| load_secret("GOOGLE_CLIENT_SECRET").ok())
901 } else {
902 None
903 }
904 });
905 let auth_url = auth_url.or(configured_auth_url).context(
906 "provider auth is missing auth_url; set it in the auth block or pass --auth-url",
907 )?;
908 let token_url = token_url.or(configured_token_url).context(
909 "provider auth is missing token_url; set it in the auth block or pass --token-url",
910 )?;
911
912 let login = OAuthLoginConfig {
913 provider: provider_name.to_string(),
914 storage_key: storage_key.clone(),
915 namespace: OAuthTokenNamespace::Provider,
916 client_id,
917 client_secret,
918 auth_url,
919 token_url,
920 scopes: requested_scopes,
921 redirect_port,
922 };
923 let tokens = oauth_login(login).await?;
924 println!(
925 "Logged in provider '{}' using profile '{}'. Stored {} scope entries.",
926 provider_name,
927 storage_key,
928 tokens.scopes.len()
929 );
930 Ok(())
931 }
932 Some(ProviderAuthConfig::GoogleAdc { .. })
933 | Some(ProviderAuthConfig::QwenOAuth { .. })
934 | Some(ProviderAuthConfig::AzureAd { .. })
935 | Some(ProviderAuthConfig::McpBridge { .. }) => {
936 let status = config
937 .provider_statuses()
938 .into_iter()
939 .find(|provider| provider.name == provider_name)
940 .map(|provider| provider.auth_status)
941 .context("provider not found while checking ADC status")?;
942 if status.configured {
943 println!(
944 "provider '{}' can use Google ADC{}",
945 provider_name,
946 status
947 .project_id
948 .as_deref()
949 .map(|project| format!(" (project {project})"))
950 .unwrap_or_default()
951 );
952 Ok(())
953 } else {
954 bail!(
955 "{}",
956 status.recovery_hint.unwrap_or_else(|| {
957 "Google ADC is not configured for this provider.".to_string()
958 })
959 )
960 }
961 }
962 None => bail!(
963 "provider '{}' is not configured and has no built-in auth preset",
964 provider_name
965 ),
966 }
967}
968
969fn print_target_auth_status(provider: &str) {
970 match load_secret(&format!("appctl_oauth::{provider}")) {
971 Ok(raw) if !raw.is_empty() => {
972 println!(
973 "target auth '{}' has stored OAuth tokens ({} bytes)",
974 provider,
975 raw.len()
976 );
977 }
978 _ => println!("no target OAuth tokens stored for '{}'", provider),
979 }
980}
981
982fn print_provider_auth_status(
983 paths: &ConfigPaths,
984 config: &AppConfig,
985 provider_name: Option<&str>,
986) -> Result<()> {
987 let statuses = config.provider_statuses_with_paths(paths);
988 if let Some(provider_name) = provider_name {
989 let provider = statuses
990 .into_iter()
991 .find(|provider| provider.name == provider_name)
992 .with_context(|| format!("provider '{}' not found in config", provider_name))?;
993 print_single_provider_status(&provider);
994 return Ok(());
995 }
996
997 for provider in statuses {
998 print_single_provider_status(&provider);
999 }
1000 Ok(())
1001}
1002
1003fn print_single_provider_status(provider: &crate::config::ResolvedProviderSummary) {
1004 println!(
1005 "{} ({:?}) model={} auth={:?} configured={}",
1006 provider.name,
1007 provider.kind,
1008 provider.model,
1009 provider.auth_status.kind,
1010 provider.auth_status.configured
1011 );
1012 if let Some(profile) = &provider.auth_status.profile {
1013 println!(" profile: {profile}");
1014 }
1015 if let Some(secret_ref) = &provider.auth_status.secret_ref {
1016 println!(" secret_ref: {secret_ref}");
1017 }
1018 if let Some(expires_at) = provider.auth_status.expires_at {
1019 println!(" expires_at: {expires_at}");
1020 }
1021 if let Some(project_id) = &provider.auth_status.project_id {
1022 println!(" project_id: {project_id}");
1023 }
1024 if let Some(recovery_hint) = &provider.auth_status.recovery_hint {
1025 println!(" hint: {recovery_hint}");
1026 }
1027}
1028
1029fn logout_provider_auth(config: &AppConfig, provider_name: &str) -> Result<()> {
1030 let provider = config
1031 .providers
1032 .iter()
1033 .find(|provider| provider.name == provider_name)
1034 .with_context(|| format!("provider '{}' not found in config", provider_name))?;
1035 let ProviderAuthConfig::OAuth2 { profile, .. } = provider
1036 .auth
1037 .as_ref()
1038 .with_context(|| format!("provider '{}' has no oauth2 auth profile", provider_name))?
1039 else {
1040 bail!(
1041 "provider '{}' is not configured for oauth2 provider auth",
1042 provider_name
1043 );
1044 };
1045 delete_provider_tokens(profile)?;
1046 println!(
1047 "deleted provider auth tokens for '{}' (profile '{}')",
1048 provider_name, profile
1049 );
1050 Ok(())
1051}
1052
1053fn provider_sample_toml(preset: Option<&str>) -> Result<String> {
1054 let preset = preset.unwrap_or("default");
1055 let sample = match preset {
1056 "gemini" => {
1057 r#"default = "gemini"
1058
1059[[provider]]
1060name = "gemini"
1061kind = "google_genai"
1062base_url = "https://generativelanguage.googleapis.com"
1063model = "gemini-2.5-pro"
1064auth = { kind = "oauth2", profile = "gemini-default", scopes = ["https://www.googleapis.com/auth/generative-language"] }
1065"#
1066 }
1067 "vertex" => {
1068 r#"default = "vertex"
1069
1070[[provider]]
1071name = "vertex"
1072kind = "google_genai"
1073base_url = "https://generativelanguage.googleapis.com"
1074model = "gemini-2.5-pro"
1075auth = { kind = "google_adc", profile = "vertex-default" }
1076"#
1077 }
1078 "qwen" => {
1079 r#"default = "qwen"
1080
1081[[provider]]
1082name = "qwen"
1083kind = "open_ai_compatible"
1084base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
1085model = "qwen3-coder-plus"
1086auth = { kind = "api_key", secret_ref = "DASHSCOPE_API_KEY" }
1087"#
1088 }
1089 "claude" => {
1090 r#"default = "claude"
1091
1092[[provider]]
1093name = "claude"
1094kind = "anthropic"
1095base_url = "https://api.anthropic.com"
1096model = "claude-sonnet-4"
1097auth = { kind = "api_key", secret_ref = "anthropic" }
1098"#
1099 }
1100 "openai" => {
1101 r#"default = "openai"
1102
1103[[provider]]
1104name = "openai"
1105kind = "open_ai_compatible"
1106base_url = "https://api.openai.com/v1"
1107model = "gpt-5"
1108auth = { kind = "api_key", secret_ref = "OPENAI_API_KEY" }
1109"#
1110 }
1111 "ollama" => {
1112 r#"default = "ollama"
1113
1114[[provider]]
1115name = "ollama"
1116kind = "open_ai_compatible"
1117base_url = "http://localhost:11434/v1"
1118model = "llama3.1"
1119auth = { kind = "none" }
1120"#
1121 }
1122 "default" => return AppConfig::sample_toml(),
1123 other => bail!("unknown preset '{}'", other),
1124 };
1125 Ok(sample.to_string())
1126}
1127
1128fn provider_auth_preset(provider_name: &str) -> Option<ProviderAuthConfig> {
1129 match provider_name {
1130 "gemini" => Some(ProviderAuthConfig::OAuth2 {
1131 profile: "gemini-default".to_string(),
1132 scopes: vec!["https://www.googleapis.com/auth/generative-language".to_string()],
1133 client_id_ref: Some("GOOGLE_CLIENT_ID".to_string()),
1134 client_secret_ref: Some("GOOGLE_CLIENT_SECRET".to_string()),
1135 auth_url: Some("https://accounts.google.com/o/oauth2/v2/auth".to_string()),
1136 token_url: Some("https://oauth2.googleapis.com/token".to_string()),
1137 }),
1138 "qwen" => Some(ProviderAuthConfig::ApiKey {
1139 secret_ref: "DASHSCOPE_API_KEY".to_string(),
1140 help_url: None,
1141 }),
1142 "claude" => Some(ProviderAuthConfig::ApiKey {
1143 secret_ref: "anthropic".to_string(),
1144 help_url: None,
1145 }),
1146 "openai" => Some(ProviderAuthConfig::ApiKey {
1147 secret_ref: "OPENAI_API_KEY".to_string(),
1148 help_url: None,
1149 }),
1150 "vertex" => Some(ProviderAuthConfig::GoogleAdc { project: None }),
1151 "ollama" => Some(ProviderAuthConfig::None),
1152 _ => None,
1153 }
1154}
1155
1156fn init_tracing(log_level: &str) -> Result<()> {
1157 let filter = EnvFilter::try_new(log_level)
1158 .or_else(|_| EnvFilter::try_new("info"))
1159 .context("invalid log filter")?;
1160
1161 fmt()
1162 .with_env_filter(filter)
1163 .with_target(false)
1164 .try_init()
1165 .ok();
1166
1167 Ok(())
1168}
1169
1170fn run_app_command(paths: &ConfigPaths, command: AppSubcommand) -> Result<()> {
1171 use crate::term::{
1172 print_flow_header, print_section_title, print_status_error, print_status_success,
1173 print_tip,
1174 };
1175
1176 let mut registry = AppRegistry::load_or_default()?;
1177
1178 match command {
1179 AppSubcommand::Add { name, path } => {
1180 let app_dir = path
1181 .map(|p| {
1182 std::fs::canonicalize(&p)
1183 .with_context(|| format!("failed to canonicalize {}", p.display()))
1184 })
1185 .unwrap_or_else(|| {
1186 std::fs::canonicalize(&paths.root).with_context(|| {
1187 format!("failed to canonicalize {}", paths.root.display())
1188 })
1189 })?;
1190
1191 if !app_dir.exists() {
1192 bail!(
1193 "app directory {} does not exist — run `appctl init` first",
1194 app_dir.display()
1195 );
1196 }
1197
1198 let chosen = name.unwrap_or_else(|| app_name_from_dir(&app_dir));
1199 print_flow_header("app add", Some("Register an app and set it active"));
1200 registry.register_and_activate(chosen.clone(), app_dir.clone());
1201 registry.save()?;
1202 print_status_success(&format!(
1203 "Registered '{}' -> {}",
1204 chosen,
1205 app_dir.display()
1206 ));
1207 print_tip("Use `appctl app use <name>` later to switch the global active app.");
1208 }
1209 AppSubcommand::List => {
1210 print_flow_header(
1211 "app list",
1212 Some("Global app contexts (~/.appctl/apps.toml)"),
1213 );
1214 if registry.apps.is_empty() {
1215 print_tip("No apps registered yet. Run `appctl app add` in an `.appctl` directory.");
1216 return Ok(());
1217 }
1218 let active = registry.active.clone();
1219 print_section_title("Registered apps");
1220 for (name, path) in ®istry.apps {
1221 let marker = if active.as_deref() == Some(name) {
1222 "*"
1223 } else {
1224 " "
1225 };
1226 println!(" {marker} {name} -> {}", path.display());
1227 }
1228 }
1229 AppSubcommand::Use { name } => {
1230 if !registry.apps.contains_key(&name) {
1231 print_status_error(&format!(
1232 "No registered app named '{name}'. Run `appctl app list` to see known apps."
1233 ));
1234 bail!("unknown app '{}'", name);
1235 }
1236 registry.active = Some(name.clone());
1237 registry.save()?;
1238 print_status_success(&format!("Active app set to '{name}'"));
1239 }
1240 AppSubcommand::Remove { name } => {
1241 match registry.remove(&name) {
1242 Some(path) => {
1243 registry.save()?;
1244 print_status_success(&format!(
1245 "Removed '{name}' (directory untouched: {})",
1246 path.display()
1247 ));
1248 }
1249 None => {
1250 print_status_error(&format!("No registered app named '{name}'"));
1251 bail!("unknown app '{}'", name);
1252 }
1253 }
1254 }
1255 }
1256
1257 Ok(())
1258}