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 pub files: Vec<String>,
42
43 #[arg(short = 'y', long = "yes")]
45 pub yes: bool,
46
47 #[arg(long = "dry-run")]
49 pub dry_run: bool,
50
51 #[arg(long = "no-push")]
53 pub no_push: bool,
54
55 #[arg(long)]
57 pub edit: bool,
58
59 #[arg(short, long)]
61 pub branch: Option<String>,
62
63 #[arg(long)]
65 pub model: Option<String>,
66
67 #[arg(long)]
69 pub level: Option<String>,
70
71 #[arg(short, long)]
73 pub auto: bool,
74
75 #[arg(short, long)]
77 pub generate: bool,
78
79 #[arg(short = 's', long = "stage-and-generate")]
81 pub stage_and_generate: bool,
82}
83
84#[derive(Subcommand, Debug)]
85pub enum Commands {
86 #[command(visible_alias = "c")]
88 Commit(CommitArgs),
89
90 #[command(visible_aliases = ["gen", "g"])]
92 Generate(ModelArgs),
93
94 #[command(visible_alias = "s")]
96 Stage(StageArgs),
97
98 Config(ConfigArgs),
100}
101
102#[derive(Args, Debug)]
103pub struct CommitArgs {
104 pub files: Vec<String>,
106
107 #[arg(short = 'y', long = "yes")]
109 pub yes: bool,
110
111 #[arg(long = "dry-run")]
113 pub dry_run: bool,
114
115 #[arg(long = "no-push")]
117 pub no_push: bool,
118
119 #[arg(long)]
121 pub edit: bool,
122
123 #[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 pub files: Vec<String>,
135
136 #[command(flatten)]
137 pub model_args: ModelArgs,
138}
139
140#[derive(Args, Debug, Clone)]
141pub struct ModelArgs {
142 #[arg(long)]
144 pub model: Option<String>,
145
146 #[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,
161
162 Path,
164
165 Init,
167
168 Edit,
170
171 Model {
173 model: Option<String>,
176
177 #[arg(long)]
179 provider: Option<String>,
180
181 #[arg(long)]
183 list: bool,
184 },
185
186 Set {
188 key: String,
190 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 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 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 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
311fn 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
352fn 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 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 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}