Skip to main content

si_git_commit/
cli.rs

1use crate::config::{suggested_models, Config, MessageLevel, Provider};
2use crate::workflow::{Workflow, WorkflowOptions};
3use clap::{Args, Parser, Subcommand};
4use std::io::{IsTerminal, Write};
5
6const AFTER_HELP: &str = "\
7SETTINGS FILE:
8  Your settings TOML lives at the path shown by `ghsi config path`
9  (override with $SI_GIT_COMMIT_CONFIG). You never need to hunt for it:
10    ghsi config          Show settings + file location
11    ghsi config model    View or change your AI model interactively
12    ghsi config init     Create the settings file with defaults
13
14EXAMPLES:
15  ghsi                       Stage all, generate, confirm, commit, push
16  ghsi -y                    Same, without the confirmation prompt
17  ghsi --dry-run             Generate the message, commit nothing
18  ghsi src/main.rs           Only stage/commit the listed files
19  ghsi stage                 Stage + generate only (old `gs` alias)
20  ghsi generate              Generate for already-staged changes
21  ghsi commit --no-push      Commit locally without pushing
22  ghsi config model gpt-5.6-luna   Switch models in one command
23
24SHELL ALIASES (optional — ghsi is already short):
25  alias gp=\"ghsi\"
26  alias gs=\"ghsi stage\"";
27
28#[derive(Parser, Debug)]
29#[command(
30    name = "ghsi",
31    version,
32    about = "Super Intelligent Git Commit — AI-powered commit messages and workflow automation",
33    long_about = None,
34    after_help = AFTER_HELP
35)]
36pub struct Cli {
37    #[command(subcommand)]
38    pub command: Option<Commands>,
39
40    /// Files to stage and commit (default workflow; empty = all changes)
41    pub files: Vec<String>,
42
43    /// Skip the confirmation prompt
44    #[arg(short = 'y', long = "yes")]
45    pub yes: bool,
46
47    /// Generate the message without committing or pushing
48    #[arg(long = "dry-run")]
49    pub dry_run: bool,
50
51    /// Commit locally without pushing
52    #[arg(long = "no-push")]
53    pub no_push: bool,
54
55    /// Open the generated message in $EDITOR before committing
56    #[arg(long)]
57    pub edit: bool,
58
59    /// Branch to push to (defaults to the current branch)
60    #[arg(short, long)]
61    pub branch: Option<String>,
62
63    /// Override the configured model for this run
64    #[arg(long)]
65    pub model: Option<String>,
66
67    /// Override message level for this run: quiet, normal, or verbose
68    #[arg(long)]
69    pub level: Option<String>,
70
71    /// (Legacy ghit flag) Stage, generate, commit, and push — the default
72    #[arg(short, long)]
73    pub auto: bool,
74
75    /// (Legacy ghit flag) Only generate a message for staged changes
76    #[arg(short, long)]
77    pub generate: bool,
78
79    /// (Legacy ghit flag) Stage files and generate (don't commit)
80    #[arg(short = 's', long = "stage-and-generate")]
81    pub stage_and_generate: bool,
82}
83
84#[derive(Subcommand, Debug)]
85pub enum Commands {
86    /// Stage, generate, commit, and push (default workflow) [alias: c]
87    #[command(visible_alias = "c")]
88    Commit(CommitArgs),
89
90    /// Generate a message for already-staged changes [aliases: gen, g]
91    #[command(visible_aliases = ["gen", "g"])]
92    Generate(ModelArgs),
93
94    /// Stage files (or all changes), then generate — no commit [alias: s]
95    #[command(visible_alias = "s")]
96    Stage(StageArgs),
97
98    /// View and edit your settings TOML (model, provider, keys)
99    Config(ConfigArgs),
100}
101
102#[derive(Args, Debug)]
103pub struct CommitArgs {
104    /// Files to stage and commit (empty = all changes)
105    pub files: Vec<String>,
106
107    /// Skip the confirmation prompt
108    #[arg(short = 'y', long = "yes")]
109    pub yes: bool,
110
111    /// Generate the message without committing or pushing
112    #[arg(long = "dry-run")]
113    pub dry_run: bool,
114
115    /// Commit locally without pushing
116    #[arg(long = "no-push")]
117    pub no_push: bool,
118
119    /// Open the generated message in $EDITOR before committing
120    #[arg(long)]
121    pub edit: bool,
122
123    /// Branch to push to (defaults to the current branch)
124    #[arg(short, long)]
125    pub branch: Option<String>,
126
127    #[command(flatten)]
128    pub model_args: ModelArgs,
129}
130
131#[derive(Args, Debug)]
132pub struct StageArgs {
133    /// Files to stage (empty = all changes)
134    pub files: Vec<String>,
135
136    #[command(flatten)]
137    pub model_args: ModelArgs,
138}
139
140#[derive(Args, Debug, Clone)]
141pub struct ModelArgs {
142    /// Override the configured model for this run
143    #[arg(long)]
144    pub model: Option<String>,
145
146    /// Override message level for this run: quiet, normal, or verbose
147    #[arg(long)]
148    pub level: Option<String>,
149}
150
151#[derive(Args, Debug)]
152pub struct ConfigArgs {
153    #[command(subcommand)]
154    pub command: Option<ConfigCommands>,
155}
156
157#[derive(Subcommand, Debug)]
158pub enum ConfigCommands {
159    /// Show current settings and the settings file location
160    Show,
161
162    /// Print the settings TOML path (so you never have to hunt for it)
163    Path,
164
165    /// Create the settings file with defaults if it does not exist
166    Init,
167
168    /// Open the settings file in $EDITOR
169    Edit,
170
171    /// Show or change the AI model (interactive when run in a terminal)
172    Model {
173        /// New model id, e.g. `ghsi config model gpt-5.6-luna`.
174        /// Omit to see the current model and pick interactively.
175        model: Option<String>,
176
177        /// Also switch provider: openai, anthropic, or openai-compatible
178        #[arg(long)]
179        provider: Option<String>,
180
181        /// List suggested current models for the provider
182        #[arg(long)]
183        list: bool,
184    },
185
186    /// Set any config key: model, provider, message_level, api_key, base_url, max_completion_tokens
187    Set {
188        /// Key to set
189        key: String,
190        /// New value
191        value: String,
192    },
193}
194
195pub async fn run() -> anyhow::Result<()> {
196    let cli = Cli::parse();
197    match run_cli(cli).await {
198        Ok(()) => Ok(()),
199        Err(e) => {
200            eprintln!("Error: {e}");
201            Err(e)
202        }
203    }
204}
205
206async fn run_cli(cli: Cli) -> anyhow::Result<()> {
207    if let Some(command) = cli.command {
208        return match command {
209            Commands::Config(args) => run_config(args),
210            Commands::Generate(model_args) => {
211                let workflow = load_workflow(&model_args)?;
212                workflow.generate_message_only().await?;
213                Ok(())
214            }
215            Commands::Stage(args) => {
216                let workflow = load_workflow(&args.model_args)?;
217                workflow.stage_and_generate(args.files).await?;
218                Ok(())
219            }
220            Commands::Commit(args) => {
221                let workflow = load_workflow(&args.model_args)?;
222                let opts = WorkflowOptions {
223                    yes: args.yes,
224                    dry_run: args.dry_run,
225                    no_push: args.no_push,
226                    branch: args.branch,
227                    edit: args.edit,
228                };
229                workflow.commit_workflow(args.files, opts).await?;
230                Ok(())
231            }
232        };
233    }
234
235    // Legacy ghit flags keep working: --generate / --stage-and-generate.
236    // --auto is the default workflow, so it needs no special handling.
237    let model_args = ModelArgs {
238        model: cli.model.clone(),
239        level: cli.level.clone(),
240    };
241
242    if cli.generate {
243        let workflow = load_workflow(&model_args)?;
244        workflow.generate_message_only().await?;
245        return Ok(());
246    }
247
248    if cli.stage_and_generate && !cli.auto {
249        let workflow = load_workflow(&model_args)?;
250        workflow.stage_and_generate(cli.files.clone()).await?;
251        return Ok(());
252    }
253
254    let workflow = load_workflow(&model_args)?;
255    let opts = WorkflowOptions {
256        yes: cli.yes,
257        dry_run: cli.dry_run,
258        no_push: cli.no_push,
259        branch: cli.branch.clone(),
260        edit: cli.edit,
261    };
262    workflow.commit_workflow(cli.files.clone(), opts).await?;
263    Ok(())
264}
265
266fn load_workflow(model_args: &ModelArgs) -> anyhow::Result<Workflow> {
267    let mut config = Config::load().map_err(|e| {
268        anyhow::anyhow!(
269            "{e}\nSettings file: {}\nRun `ghsi config` to inspect your settings, \
270             or `ghsi config init` to create the file.",
271            Config::settings_reference()
272        )
273    })?;
274    apply_model_overrides(&mut config, model_args)?;
275    // Fail fast with a settings-pointing error before doing git work.
276    config.require_api_key()?;
277    Ok(Workflow::new(config))
278}
279
280fn apply_model_overrides(config: &mut Config, args: &ModelArgs) -> anyhow::Result<()> {
281    if let Some(model) = &args.model {
282        if model.trim().is_empty() {
283            anyhow::bail!("--model must not be empty");
284        }
285        config.model = model.trim().to_string();
286    }
287    if let Some(level) = &args.level {
288        config.message_level = level
289            .parse::<MessageLevel>()
290            .map_err(|e: crate::SiError| anyhow::anyhow!(e.to_string()))?;
291    }
292    Ok(())
293}
294
295fn save_config(config: &Config) -> anyhow::Result<std::path::PathBuf> {
296    // Save back to the file the user already has, if any, so a
297    // hand-created ~/.si-git-commit.toml is updated in place.
298    // Preserve the key stored in the file: a key picked up from the
299    // environment (e.g. system-wide OPENAI_API_KEY) must not be copied
300    // into the settings file just because the model changed.
301    let mut to_save = config.clone();
302    to_save.api_key = Config::stored_api_key().unwrap_or_default();
303    if let Some(existing) = Config::find_existing_path() {
304        to_save.save_to(&existing)?;
305        Ok(existing)
306    } else {
307        Ok(to_save.save()?)
308    }
309}
310
311/// Save exactly what is in `config`, including its API key.
312/// Used only when the user explicitly sets `api_key`.
313fn save_config_verbatim(config: &Config) -> anyhow::Result<std::path::PathBuf> {
314    if let Some(existing) = Config::find_existing_path() {
315        config.save_to(&existing)?;
316        Ok(existing)
317    } else {
318        Ok(config.save()?)
319    }
320}
321
322fn run_config(args: ConfigArgs) -> anyhow::Result<()> {
323    match args.command {
324        None | Some(ConfigCommands::Show) => config_show(),
325        Some(ConfigCommands::Path) => {
326            println!("{}", Config::settings_reference());
327            if Config::find_existing_path().is_none() {
328                println!("(file does not exist yet — create it with `ghsi config init`)");
329            }
330            Ok(())
331        }
332        Some(ConfigCommands::Init) => config_init(),
333        Some(ConfigCommands::Edit) => config_edit(),
334        Some(ConfigCommands::Model {
335            model,
336            provider,
337            list,
338        }) => config_model(model, provider, list),
339        Some(ConfigCommands::Set { key, value }) => config_set(&key, &value),
340    }
341}
342
343fn key_tail(key: &str) -> String {
344    let key = key.trim();
345    if key.len() > 4 {
346        format!("…{}", &key[key.len() - 4..])
347    } else {
348        "set".to_string()
349    }
350}
351
352/// One-line, source-aware API key status for `ghsi config`.
353/// A system-wide key (e.g. `OPENAI_API_KEY` exported from your shell
354/// profile) is used automatically — say so, and explain the override.
355fn api_key_line(config: &Config) -> String {
356    let stored = Config::stored_api_key();
357    let env_found = config.env_api_key();
358    match (stored, env_found) {
359        (Some(key), Some((name, _))) => format!(
360            "set in settings file ({}) — overrides {name} from your environment",
361            key_tail(&key)
362        ),
363        (Some(key), None) => format!("set in settings file ({})", key_tail(&key)),
364        (None, Some((name, value))) => format!(
365            "✓ {name} found in environment ({}) — used automatically; \
366             to use a different key just for ghsi, run `ghsi config set api_key <key>`",
367            key_tail(&value)
368        ),
369        (None, None) => format!(
370            "not set — export {}=<your-key> or run `ghsi config set api_key <key>`",
371            config.provider.env_key_name()
372        ),
373    }
374}
375
376fn config_show() -> anyhow::Result<()> {
377    let config = Config::load_or_default();
378    let existing = Config::find_existing_path();
379    println!("⚙️  si-git-commit (ghsi) settings");
380    println!("  Settings file: {}", Config::settings_reference());
381    match &existing {
382        Some(p) => println!("  Status:        found ({})", p.display()),
383        None => println!("  Status:        not created yet — run `ghsi config init`"),
384    }
385    println!("  Provider:      {}", config.provider.as_str());
386    println!("  Model:         {}", config.model);
387    println!("  Message level: {}", config.message_level.as_str());
388    println!(
389        "  Token budget:  {} (sent as max_completion_tokens to OpenAI, max_tokens to Anthropic)",
390        config.max_completion_tokens
391    );
392    println!(
393        "  Base URL:      {}",
394        config.base_url.as_deref().unwrap_or("(provider default)")
395    );
396    println!("  API key:       {}", api_key_line(&config));
397    println!();
398    println!("Change the model without editing this file:");
399    println!("  ghsi config model <name>     e.g. ghsi config model gpt-5.6-luna");
400    println!("  ghsi config model            interactive picker (in a terminal)");
401    println!("  ghsi config model --list     suggested current models");
402    println!("Open the file directly: ghsi config edit   (path: ghsi config path)");
403    Ok(())
404}
405
406fn config_init() -> anyhow::Result<()> {
407    let (path, created) = Config::init_if_missing()?;
408    if created {
409        println!("✓ Created settings file: {}", path.display());
410    } else {
411        println!("Settings file already exists: {}", path.display());
412    }
413    println!();
414    println!("Next steps:");
415    let config = Config::load_or_default();
416    match config.env_api_key() {
417        Some((name, _)) => {
418            println!(
419                "  1. ✓ {name} found in your environment — used automatically, \
420                 no setup needed."
421            );
422            println!(
423                "     (Optional: use a different key just for ghsi with \
424                 `ghsi config set api_key <key>` — it overrides the environment.)"
425            );
426        }
427        None => {
428            println!(
429                "  1. Add your API key: export {}=<your-key>  \
430                 (or `ghsi config set api_key <key>`)",
431                config.provider.env_key_name()
432            );
433        }
434    }
435    println!("  2. Pick a model:     ghsi config model");
436    println!("  3. Commit with AI:   ghsi");
437    Ok(())
438}
439
440fn config_edit() -> anyhow::Result<()> {
441    let (path, _) = Config::init_if_missing()?;
442    println!("Opening {} …", path.display());
443    let editor = std::env::var("VISUAL")
444        .ok()
445        .filter(|v| !v.trim().is_empty())
446        .or_else(|| {
447            std::env::var("EDITOR")
448                .ok()
449                .filter(|v| !v.trim().is_empty())
450        })
451        .unwrap_or_else(|| {
452            if cfg!(windows) {
453                "notepad".to_string()
454            } else {
455                "vi".to_string()
456            }
457        });
458    let status = std::process::Command::new(&editor).arg(&path).status()?;
459    if !status.success() {
460        anyhow::bail!("editor `{editor}` exited with {status}");
461    }
462    // Validate the edited file still parses.
463    Config::load_from(&path)?;
464    println!("✓ Settings saved and valid: {}", path.display());
465    Ok(())
466}
467
468fn config_model(model: Option<String>, provider: Option<String>, list: bool) -> anyhow::Result<()> {
469    let mut config = Config::load_or_default();
470
471    if let Some(p) = &provider {
472        config.provider = p
473            .parse::<Provider>()
474            .map_err(|e: crate::SiError| anyhow::anyhow!(e.to_string()))?;
475    }
476
477    if list {
478        println!(
479            "Suggested current models for provider `{}`:",
480            config.provider.as_str()
481        );
482        for m in suggested_models(config.provider) {
483            let marker = if *m == config.model { " (current)" } else { "" };
484            println!("  - {m}{marker}");
485        }
486        println!();
487        println!("Any current model id works — set it with: ghsi config model <name>");
488        println!("Settings file: {}", Config::settings_reference());
489        return Ok(());
490    }
491
492    if let Some(new_model) = model {
493        let new_model = new_model.trim().to_string();
494        if new_model.is_empty() {
495            anyhow::bail!("model must not be empty");
496        }
497        let old = config.model.clone();
498        config.model = new_model;
499        let path = save_config(&config)?;
500        println!("✓ Model changed: {old} → {}", config.model);
501        println!("  Provider: {}", config.provider.as_str());
502        println!("  Settings file: {}", path.display());
503        return Ok(());
504    }
505
506    // No explicit model: show current, then offer to change interactively.
507    println!("Current model:   {}", config.model);
508    println!("Provider:        {}", config.provider.as_str());
509    println!("Settings file:   {}", Config::settings_reference());
510    println!();
511
512    if !std::io::stdin().is_terminal() {
513        println!("To change it: ghsi config model <name>   (e.g. ghsi config model gpt-5.6-luna)");
514        println!("Suggestions:   ghsi config model --list");
515        return Ok(());
516    }
517
518    let suggestions = suggested_models(config.provider);
519    println!("Suggested current models:");
520    for (i, m) in suggestions.iter().enumerate() {
521        let marker = if *m == config.model { " (current)" } else { "" };
522        println!("  {}. {m}{marker}", i + 1);
523    }
524    println!();
525    print!(
526        "Enter a number, a model id, or press Enter to keep \"{}\": ",
527        config.model
528    );
529    std::io::stdout().flush()?;
530
531    let mut input = String::new();
532    std::io::stdin().read_line(&mut input)?;
533    let input = input.trim();
534
535    if input.is_empty() {
536        println!("Kept model: {}", config.model);
537        return Ok(());
538    }
539
540    let chosen = if let Ok(n) = input.parse::<usize>() {
541        if n >= 1 && n <= suggestions.len() {
542            suggestions[n - 1].to_string()
543        } else {
544            anyhow::bail!("choice {n} is out of range (1-{})", suggestions.len());
545        }
546    } else {
547        input.to_string()
548    };
549
550    if chosen == config.model && provider.is_none() {
551        println!("Kept model: {}", config.model);
552        return Ok(());
553    }
554
555    config.model = chosen;
556    let path = save_config(&config)?;
557    println!("✓ Model changed to: {}", config.model);
558    println!("  Provider: {}", config.provider.as_str());
559    println!("  Settings file: {}", path.display());
560    Ok(())
561}
562
563fn config_set(key: &str, value: &str) -> anyhow::Result<()> {
564    let mut config = Config::load_or_default();
565    let value = value.trim().to_string();
566    match key {
567        "model" => {
568            if value.is_empty() {
569                anyhow::bail!("model must not be empty");
570            }
571            config.model = value;
572        }
573        "provider" => {
574            config.provider = value
575                .parse::<Provider>()
576                .map_err(|e: crate::SiError| anyhow::anyhow!(e.to_string()))?;
577        }
578        "message_level" | "level" => {
579            config.message_level = value
580                .parse::<MessageLevel>()
581                .map_err(|e: crate::SiError| anyhow::anyhow!(e.to_string()))?;
582        }
583        "api_key" => config.api_key = value,
584        "base_url" => {
585            config.base_url = if value.is_empty() { None } else { Some(value) };
586        }
587        "max_completion_tokens" | "max_tokens" => {
588            config.max_completion_tokens = value
589                .parse::<u32>()
590                .map_err(|_| anyhow::anyhow!("max_completion_tokens must be a positive integer"))?;
591            if config.max_completion_tokens == 0 {
592                anyhow::bail!("max_completion_tokens must be greater than 0");
593            }
594        }
595        other => {
596            anyhow::bail!(
597                "unknown config key `{other}`. Valid keys: model, provider, message_level, \
598                 api_key, base_url, max_completion_tokens. Run `ghsi config` to see your settings \
599                 (file: {}).",
600                Config::settings_reference()
601            );
602        }
603    }
604    let path = if key == "api_key" {
605        save_config_verbatim(&config)?
606    } else {
607        save_config(&config)?
608    };
609    println!("✓ Set {key} in {}", path.display());
610    config_show()?;
611    Ok(())
612}