Skip to main content

appctl/
cli.rs

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    /// Set up a `.appctl` directory (models, auth, and provider) interactively.
47    Init,
48    /// Introspect your stack and (re)build schema and OpenAPI tool definitions.
49    Sync(SyncArgs),
50    /// Interactive session with the AI for this app's tools.
51    Chat(ChatArgs),
52    /// Run a single prompt and exit (non-interactive).
53    Run(RunArgs),
54    /// Check connectivity, config, and which HTTP routes are verified.
55    Doctor(DoctorArgsCli),
56    /// Inspect and undo tool runs stored in the local history database.
57    History(HistoryArgs),
58    /// Expose the agent and tools over HTTP (MCP- and AI-friendly).
59    Serve(ServeArgs),
60    /// Inspect config, TOML samples, and keychain secrets.
61    Config(ConfigArgs),
62    /// List, install, and load appctl extension plugins.
63    Plugin(PluginArgs),
64    /// Log in to a target, cloud provider, or direct API.
65    Auth(AuthArgs),
66    /// Run a built-in MCP (Model Context Protocol) stdio server for this app.
67    Mcp(McpArgs),
68    /// Manage known app contexts and the global active app.
69    App(AppArgs),
70}
71
72#[derive(Debug, Args)]
73pub struct DoctorArgsCli {
74    /// Write provenance=verified for routes that did not return 404.
75    #[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    /// List models available to the active or selected provider.
86    Models {
87        /// Name of a provider entry from `.appctl/config.toml` (defaults to the configured default).
88        #[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    /// Register an app directory (defaults to detected local `.appctl`) and activate it.
102    Add {
103        /// Name to register. Defaults to the parent directory name.
104        name: Option<String>,
105        /// App directory to register. Defaults to the resolved `--app-dir`.
106        #[arg(long)]
107        path: Option<PathBuf>,
108    },
109    /// List all registered apps and show the active one.
110    List,
111    /// Set the global active app by name.
112    Use { name: String },
113    /// Remove a registered app by name.
114    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    /// Deprecated alias for `appctl auth target login`.
138    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    /// Deprecated alias for `appctl auth target status`.
154    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    /// Invoke a dynamic plugin by name, e.g. `--plugin airtable`.
242    #[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    /// Block inferred HTTP tools until `appctl doctor --write` marks them verified.
273    #[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    /// Auto-approve mutating tools (on by default for non-interactive `serve`).
321    #[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    /// Store a secret in the OS keychain (service `appctl`). Env vars still override at runtime.
340    SetSecret {
341        name: String,
342        /// Value to store; if omitted, read from stdin (TTY prompt in future).
343        #[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    // If `source` points at an existing file, just copy it.
709    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    // Otherwise try to `cargo install` the plugin from a git url or crate name.
718    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    // Walk the target dir and copy any cdylib artifact into ~/.appctl/plugins/
755    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 &registry.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}