anveesa 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib.rs ADDED
@@ -0,0 +1,777 @@
1
+ pub mod cli;
2
+ pub mod config;
3
+ pub mod display;
4
+ pub mod image;
5
+ pub mod mcp;
6
+ pub mod prompt;
7
+ pub mod provider;
8
+ pub mod session;
9
+ pub mod tools;
10
+ pub mod tui;
11
+ pub mod web;
12
+ pub mod workspace;
13
+
14
+ use std::{
15
+ fs,
16
+ io::{self, IsTerminal},
17
+ time::Instant,
18
+ };
19
+
20
+ use anyhow::{Context, Result};
21
+ use clap::{CommandFactory, Parser};
22
+ use tokio::sync::mpsc;
23
+
24
+ use crate::{
25
+ cli::{AskOptions, Cli, Command, ConfigCommand, SessionsCommand},
26
+ config::{
27
+ AppConfig, ProviderConfig, config_path, init_config, print_path, set_default_model,
28
+ set_default_provider,
29
+ },
30
+ display::{
31
+ print_help_inline, print_input_separator, print_session_header, print_session_info,
32
+ print_status_inline, prompt_label, render_stream, term_width,
33
+ },
34
+ image::{attach_image, grab_clipboard_image, image_fingerprint, parse_attach_command},
35
+ prompt::{PromptRead, read_prompt_line},
36
+ provider::{
37
+ ApprovalPolicy, ChatMessage, ChatRole, ImageAttachment, PromptRequest, TurnResult, Usage,
38
+ },
39
+ session::{
40
+ append_repl_history, legacy_session_path, load_interactive_session, purge_stale_sessions,
41
+ repl_history_path, repl_session_path, save_interactive_session,
42
+ },
43
+ workspace::workspace_context_for,
44
+ };
45
+
46
+ #[derive(Debug, Clone, Copy)]
47
+ pub enum RenderMode {
48
+ Interactive,
49
+ OneShot,
50
+ }
51
+
52
+ pub async fn run_anveesa() -> Result<()> {
53
+ run_cli(Cli::parse()).await
54
+ }
55
+
56
+ async fn run_cli(cli: Cli) -> Result<()> {
57
+ match cli.command {
58
+ Some(Command::Ask(args)) => run_ask(args.options, args.prompt).await,
59
+ Some(Command::Providers) => list_providers(),
60
+ Some(Command::Config(args)) => run_config(args.command),
61
+ Some(Command::Sessions(args)) => run_sessions(args.command),
62
+ Some(Command::Web(args)) => web::run_web(args.options, args.port).await,
63
+ None if cli.prompt.is_empty() && cli.ask_options.stdin => {
64
+ run_ask(cli.ask_options, cli.prompt).await
65
+ }
66
+ None if cli.prompt.is_empty() && std::io::stdin().is_terminal() => {
67
+ run_interactive(cli.ask_options).await
68
+ }
69
+ None if cli.prompt.is_empty() => {
70
+ Cli::command().print_help()?;
71
+ println!();
72
+ Ok(())
73
+ }
74
+ None => run_ask(cli.ask_options, cli.prompt).await,
75
+ }
76
+ }
77
+
78
+ async fn run_interactive(options: AskOptions) -> Result<()> {
79
+ let config = AppConfig::load()?;
80
+ let mut provider_name = config
81
+ .provider_name(options.provider.as_deref())?
82
+ .to_string();
83
+ let provider = config
84
+ .providers
85
+ .get(&provider_name)
86
+ .with_context(|| format!("unknown provider '{provider_name}'"))?;
87
+ let _tools_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
88
+ let mut images_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
89
+ let model = options
90
+ .model
91
+ .clone()
92
+ .or_else(|| provider.default_model().map(str::to_string));
93
+ let cwd = std::env::current_dir().context("failed to resolve current directory")?;
94
+ let workspace_context = workspace_context_for(&cwd).ok();
95
+ let policy = if options.yes {
96
+ ApprovalPolicy::Allow
97
+ } else {
98
+ ApprovalPolicy::Prompt
99
+ };
100
+
101
+ let mut session_options = AskOptions {
102
+ provider: Some(provider_name.clone()),
103
+ model,
104
+ system: options.system,
105
+ stdin: false,
106
+ yes: options.yes,
107
+ };
108
+
109
+ let mut accumulated_usage = Usage::default();
110
+
111
+ purge_stale_sessions();
112
+
113
+ let session_path = repl_session_path(&cwd);
114
+ let loaded_session = session_path
115
+ .as_deref()
116
+ .and_then(|path| load_interactive_session(path, &cwd))
117
+ .or_else(|| {
118
+ // Migrate from the legacy single session.json if it matches our cwd.
119
+ let legacy = legacy_session_path()?;
120
+ let session = load_interactive_session(&legacy, &cwd)?;
121
+ let _ = fs::remove_file(&legacy);
122
+ Some(session)
123
+ });
124
+ let mut history = loaded_session
125
+ .as_ref()
126
+ .map(|s| s.messages.clone())
127
+ .unwrap_or_default();
128
+ // saved_at at load time — used only for the startup header so it shows when the previous
129
+ // run ended, not the current run's save time.
130
+ let session_saved_at = loaded_session
131
+ .as_ref()
132
+ .filter(|s| s.saved_at > 0)
133
+ .map(|s| s.saved_at);
134
+ // tracks the most recent successful save this run — kept fresh for /session display
135
+ let mut last_saved_at: u64 = session_saved_at.unwrap_or(0);
136
+ // Per-project config: .anveesa.toml (extended) or .anveesa (plain system prompt)
137
+ if let Ok(raw) = fs::read_to_string(cwd.join(".anveesa.toml")) {
138
+ if let Ok(cfg) = toml::from_str::<toml::Value>(&raw) {
139
+ if session_options.system.is_none()
140
+ && let Some(sp) = cfg.get("system_prompt").and_then(|v| v.as_str())
141
+ {
142
+ session_options.system = Some(sp.trim().to_string());
143
+ }
144
+ // Override model if not set by CLI
145
+ if session_options.model.is_none()
146
+ && let Some(m) = cfg.get("model").and_then(|v| v.as_str())
147
+ {
148
+ session_options.model = Some(m.to_string());
149
+ }
150
+ // auto_approve
151
+ if let Some(true) = cfg.get("auto_approve").and_then(|v| v.as_bool()) {
152
+ // handled by policy below — set yes=true equivalent
153
+ images_available = true; // keep as-is; just document capability
154
+ }
155
+ }
156
+ } else if session_options.system.is_none()
157
+ && let Ok(text) = fs::read_to_string(cwd.join(".anveesa"))
158
+ {
159
+ let trimmed = text.trim().to_string();
160
+ if !trimmed.is_empty() {
161
+ session_options.system = Some(trimmed);
162
+ }
163
+ }
164
+
165
+ let history_path = repl_history_path();
166
+ // Load prompt history for ↑/↓ recall (one entry per line, newest at end).
167
+ let input_history: Vec<String> = history_path
168
+ .as_deref()
169
+ .and_then(|p| fs::read_to_string(p).ok())
170
+ .map(|c| {
171
+ c.lines()
172
+ .filter(|l| !l.is_empty())
173
+ .map(String::from)
174
+ .collect()
175
+ })
176
+ .unwrap_or_default();
177
+
178
+ print_session_header(
179
+ &provider_name,
180
+ session_options.model.as_deref().unwrap_or("-"),
181
+ history.len() / 2,
182
+ !history.is_empty(),
183
+ session_saved_at,
184
+ );
185
+
186
+ let is_tty = io::stdout().is_terminal();
187
+
188
+ // ── TUI mode ──────────────────────────────────────────────────────────────
189
+ if is_tty {
190
+ // Connect to any configured MCP servers.
191
+ let mcp_manager = if !config.mcp.is_empty() {
192
+ let m = mcp::McpManager::connect(&config.mcp).await;
193
+ Some(std::sync::Arc::new(m))
194
+ } else {
195
+ None
196
+ };
197
+
198
+ // Spawn a background task to read keyboard events (crossterm::event::read is blocking).
199
+ let (key_tx, key_rx) = tokio::sync::mpsc::unbounded_channel();
200
+ tokio::task::spawn_blocking(move || {
201
+ while let Ok(ev) = crossterm::event::read() {
202
+ if key_tx.send(ev).is_err() {
203
+ break;
204
+ }
205
+ }
206
+ });
207
+
208
+ let short_cwd = std::env::var("HOME")
209
+ .map(|h| cwd.display().to_string().replacen(&h, "~", 1))
210
+ .unwrap_or_else(|_| cwd.display().to_string());
211
+
212
+ let app = tui::App::new(
213
+ provider_name.clone(),
214
+ session_options
215
+ .model
216
+ .clone()
217
+ .unwrap_or_else(|| "-".to_string()),
218
+ short_cwd,
219
+ history,
220
+ images_available,
221
+ session_path.clone(),
222
+ last_saved_at,
223
+ input_history,
224
+ config,
225
+ session_options,
226
+ workspace_context,
227
+ policy,
228
+ key_rx,
229
+ mcp_manager,
230
+ );
231
+
232
+ tui::run(app).await?;
233
+ return Ok(());
234
+ }
235
+ // ── Fallback: plain REPL (non-TTY / piped) ────────────────────────────────
236
+
237
+ let width = term_width();
238
+ let label = prompt_label(is_tty);
239
+ // Fingerprint of the last clipboard image we attached — prevents re-attaching
240
+ // the same screenshot on every subsequent turn until the user copies something new.
241
+ let mut last_image_fp: Option<String> = None;
242
+ let mut pending_image: Option<ImageAttachment> = None;
243
+ let mut paste_count = 0usize;
244
+
245
+ loop {
246
+ print_input_separator(is_tty, width);
247
+ let (line, ctrl_v_image) = match read_prompt_line(
248
+ &label,
249
+ width,
250
+ &mut paste_count,
251
+ images_available,
252
+ &input_history,
253
+ ) {
254
+ Ok(PromptRead::Line(line, img)) => (line, img),
255
+ Ok(PromptRead::Interrupted) => continue,
256
+ Ok(PromptRead::Eof) => {
257
+ println!();
258
+ break;
259
+ }
260
+ Err(error) => return Err(error).context("failed to read interactive prompt"),
261
+ };
262
+
263
+ // Ctrl+V image takes precedence over a previously pending image.
264
+ if let Some(img) = ctrl_v_image {
265
+ last_image_fp = Some(image_fingerprint(&img));
266
+ pending_image = Some(img);
267
+ }
268
+
269
+ let prompt = line.trim().to_string();
270
+ if prompt.is_empty() {
271
+ continue;
272
+ }
273
+
274
+ print_input_separator(is_tty, width);
275
+
276
+ match prompt.as_str() {
277
+ "/exit" | "/quit" | ":q" => break,
278
+ "/clear" => {
279
+ history.clear();
280
+ last_image_fp = None;
281
+ pending_image = None;
282
+ paste_count = 0;
283
+ if let Some(path) = &session_path {
284
+ let _ = fs::remove_file(path);
285
+ }
286
+ if is_tty {
287
+ println!("\x1b[2m Conversation cleared.\x1b[0m");
288
+ } else {
289
+ println!("conversation cleared");
290
+ }
291
+ continue;
292
+ }
293
+ "/help" => {
294
+ print_help_inline(is_tty);
295
+ continue;
296
+ }
297
+ "/session" => {
298
+ print_session_info(
299
+ is_tty,
300
+ session_path.as_deref(),
301
+ history.len() / 2,
302
+ Some(last_saved_at).filter(|&t| t > 0),
303
+ );
304
+ continue;
305
+ }
306
+ s if s.starts_with("/export") => {
307
+ let arg = s.strip_prefix("/export").unwrap().trim();
308
+ let path = if arg.is_empty() {
309
+ cwd.join(format!("anveesa-export-{}.md", unix_now()))
310
+ } else {
311
+ std::path::PathBuf::from(arg)
312
+ };
313
+ match export_conversation(&path, &history) {
314
+ Ok(()) => {
315
+ if is_tty {
316
+ eprintln!("\x1b[2m Exported to {}\x1b[0m", path.display());
317
+ } else {
318
+ println!("exported to {}", path.display());
319
+ }
320
+ }
321
+ Err(e) => eprintln!("\x1b[1;31m✗\x1b[0m {e:#}"),
322
+ }
323
+ continue;
324
+ }
325
+ "/status" => {
326
+ print_status_inline(
327
+ is_tty,
328
+ &provider_name,
329
+ session_options.model.as_deref(),
330
+ &cwd,
331
+ history.len() / 2,
332
+ &accumulated_usage,
333
+ );
334
+ continue;
335
+ }
336
+ s if s.starts_with("/model") => {
337
+ let arg = s.strip_prefix("/model").unwrap().trim();
338
+ if arg.is_empty() {
339
+ let current = session_options
340
+ .model
341
+ .as_deref()
342
+ .unwrap_or("(provider default)");
343
+ if is_tty {
344
+ println!("\x1b[2m model: {current}\x1b[0m");
345
+ } else {
346
+ println!("model: {current}");
347
+ }
348
+ } else {
349
+ session_options.model = Some(arg.to_string());
350
+ if is_tty {
351
+ println!("\x1b[2m Switched to model: {arg}\x1b[0m");
352
+ } else {
353
+ println!("switched model: {arg}");
354
+ }
355
+ }
356
+ continue;
357
+ }
358
+ s if s.starts_with("/provider") => {
359
+ let arg = s.strip_prefix("/provider").unwrap().trim();
360
+ if arg.is_empty() {
361
+ if is_tty {
362
+ println!(
363
+ "\x1b[2m provider: {provider_name} model: {}\x1b[0m",
364
+ session_options.model.as_deref().unwrap_or("(default)")
365
+ );
366
+ } else {
367
+ println!("provider: {provider_name}");
368
+ }
369
+ } else if !config.providers.contains_key(arg) {
370
+ if is_tty {
371
+ eprintln!(
372
+ "\x1b[1;31m✗\x1b[0m unknown provider '{arg}' — run: anveesa providers"
373
+ );
374
+ } else {
375
+ eprintln!("error: unknown provider '{arg}'");
376
+ }
377
+ } else {
378
+ let new_cfg = config.providers.get(arg).unwrap();
379
+ images_available = matches!(new_cfg, ProviderConfig::OpenAiCompatible(_));
380
+ // Reset model to new provider's default
381
+ session_options.model = new_cfg.default_model().map(str::to_string);
382
+ provider_name = arg.to_string();
383
+ session_options.provider = Some(arg.to_string());
384
+ let model_display = session_options.model.as_deref().unwrap_or("(default)");
385
+ if is_tty {
386
+ println!(
387
+ "\x1b[2m Switched to provider: {arg} model: {model_display}\x1b[0m"
388
+ );
389
+ } else {
390
+ println!("switched provider: {arg} model: {model_display}");
391
+ }
392
+ }
393
+ continue;
394
+ }
395
+ _ => {}
396
+ }
397
+ if let Some(path) = parse_attach_command(&prompt) {
398
+ if !images_available {
399
+ if is_tty {
400
+ eprintln!(
401
+ "\x1b[1;31m✗\x1b[0m image attachments require an openai-compatible provider"
402
+ );
403
+ } else {
404
+ eprintln!("error: image attachments require an openai-compatible provider");
405
+ }
406
+ continue;
407
+ }
408
+
409
+ match attach_image(path.as_deref()) {
410
+ Ok(image) => {
411
+ last_image_fp = Some(image_fingerprint(&image));
412
+ pending_image = Some(image);
413
+ if is_tty {
414
+ eprintln!("\x1b[2m Image attached.\x1b[0m");
415
+ } else {
416
+ eprintln!("image attached");
417
+ }
418
+ }
419
+ Err(error) => {
420
+ if is_tty {
421
+ eprintln!("\x1b[1;31m✗\x1b[0m {error:#}");
422
+ } else {
423
+ eprintln!("error: {error:#}");
424
+ }
425
+ }
426
+ }
427
+ continue;
428
+ }
429
+ if let Some(path) = &history_path {
430
+ let _ = append_repl_history(path, prompt.as_str());
431
+ }
432
+
433
+ // Use an explicitly attached image first. Otherwise, keep the legacy
434
+ // convenience behavior: attach a newly copied clipboard image once.
435
+ let image = if pending_image.is_some() {
436
+ pending_image.take()
437
+ } else if is_tty && images_available {
438
+ grab_clipboard_image().and_then(|img| {
439
+ let fp = image_fingerprint(&img);
440
+ if last_image_fp.as_deref() == Some(&fp) {
441
+ None // same image — don't re-attach
442
+ } else {
443
+ last_image_fp = Some(fp);
444
+ Some(img)
445
+ }
446
+ })
447
+ } else {
448
+ None
449
+ };
450
+ if is_tty && image.is_some() {
451
+ eprintln!("\x1b[2m Screenshot from clipboard attached.\x1b[0m");
452
+ }
453
+
454
+ let ask_result = tokio::select! {
455
+ r = ask_streaming(
456
+ &config,
457
+ &session_options,
458
+ prompt.clone(),
459
+ &history,
460
+ workspace_context.as_deref(),
461
+ policy,
462
+ image,
463
+ RenderMode::Interactive,
464
+ ) => Some(r),
465
+ _ = tokio::signal::ctrl_c() => None,
466
+ };
467
+
468
+ match ask_result {
469
+ Some(Ok(result)) => {
470
+ println!();
471
+ if let Some(u) = result.usage {
472
+ accumulated_usage.prompt_tokens += u.prompt_tokens;
473
+ accumulated_usage.completion_tokens += u.completion_tokens;
474
+ accumulated_usage.total_tokens += u.total_tokens;
475
+ accumulated_usage.cache_read_tokens += u.cache_read_tokens;
476
+ accumulated_usage.cache_write_tokens += u.cache_write_tokens;
477
+ }
478
+ history.push(ChatMessage::user(prompt));
479
+ history.push(ChatMessage::assistant(result.text));
480
+ if let Some(path) = &session_path
481
+ && save_interactive_session(
482
+ path,
483
+ &cwd,
484
+ &provider_name,
485
+ &session_options,
486
+ &history,
487
+ )
488
+ .is_ok()
489
+ {
490
+ last_saved_at = unix_now();
491
+ }
492
+ }
493
+ Some(Err(error)) => {
494
+ if is_tty {
495
+ eprintln!("\x1b[1;31m✗\x1b[0m {error:#}");
496
+ } else {
497
+ eprintln!("error: {error:#}");
498
+ }
499
+ println!();
500
+ history.push(ChatMessage::user(prompt));
501
+ history.push(ChatMessage::assistant(format!(
502
+ "The previous turn failed inside Anveesa before a final answer was produced: {error:#}"
503
+ )));
504
+ if let Some(path) = &session_path
505
+ && save_interactive_session(
506
+ path,
507
+ &cwd,
508
+ &provider_name,
509
+ &session_options,
510
+ &history,
511
+ )
512
+ .is_ok()
513
+ {
514
+ last_saved_at = unix_now();
515
+ }
516
+ }
517
+ None => {
518
+ // Ctrl+C during streaming — save current history and exit cleanly.
519
+ println!();
520
+ if is_tty {
521
+ eprintln!("\x1b[2m ^C Session saved.\x1b[0m");
522
+ } else {
523
+ eprintln!("interrupted");
524
+ }
525
+ if let Some(path) = &session_path {
526
+ let _ = save_interactive_session(
527
+ path,
528
+ &cwd,
529
+ &provider_name,
530
+ &session_options,
531
+ &history,
532
+ );
533
+ }
534
+ break;
535
+ }
536
+ }
537
+ }
538
+
539
+ if let Some(path) = &session_path {
540
+ let _ = save_interactive_session(path, &cwd, &provider_name, &session_options, &history);
541
+ }
542
+ Ok(())
543
+ }
544
+
545
+ fn run_sessions(command: SessionsCommand) -> Result<()> {
546
+ match command {
547
+ SessionsCommand::List => session::list_sessions(),
548
+ SessionsCommand::Clear { all } => session::clear_sessions(all),
549
+ }
550
+ }
551
+
552
+ async fn run_ask(options: AskOptions, prompt_parts: Vec<String>) -> Result<()> {
553
+ let config = AppConfig::load()?;
554
+ let provider_name = config
555
+ .provider_name(options.provider.as_deref())?
556
+ .to_string();
557
+ config
558
+ .providers
559
+ .get(&provider_name)
560
+ .with_context(|| format!("unknown provider '{provider_name}'"))?;
561
+ let prompt = build_prompt(prompt_parts, options.stdin)?;
562
+ let workspace_context = workspace::workspace_context().ok();
563
+ let policy = one_shot_policy(options.yes, io::stdin().is_terminal());
564
+
565
+ ask_streaming(
566
+ &config,
567
+ &options,
568
+ prompt,
569
+ &[],
570
+ workspace_context.as_deref(),
571
+ policy,
572
+ None,
573
+ RenderMode::OneShot,
574
+ )
575
+ .await?;
576
+ Ok(())
577
+ }
578
+
579
+ #[allow(clippy::too_many_arguments)]
580
+ async fn ask_streaming(
581
+ config: &AppConfig,
582
+ options: &AskOptions,
583
+ prompt: String,
584
+ history: &[ChatMessage],
585
+ workspace_context: Option<&str>,
586
+ policy: ApprovalPolicy,
587
+ image: Option<ImageAttachment>, // single-image path kept for REPL compatibility
588
+ mode: RenderMode,
589
+ ) -> Result<TurnResult> {
590
+ let provider_name = config
591
+ .provider_name(options.provider.as_deref())?
592
+ .to_string();
593
+ let (tx, rx) = mpsc::unbounded_channel();
594
+ let started = Instant::now();
595
+ let renderer = tokio::spawn(render_stream(rx, mode, started));
596
+
597
+ let request = PromptRequest {
598
+ prompt,
599
+ model: options.model.clone(),
600
+ system: options.system.clone(),
601
+ workspace_context: workspace_context.map(str::to_string),
602
+ history: history.to_vec(),
603
+ images: image.into_iter().collect(),
604
+ mcp: None, // REPL path: MCP not yet wired here
605
+ };
606
+
607
+ let result = provider::ask(config, &provider_name, request, policy, &tx).await;
608
+ drop(tx);
609
+ let _ = renderer.await;
610
+ result
611
+ }
612
+
613
+ /// Export a conversation history as markdown to the given path.
614
+ ///
615
+ /// # Examples
616
+ ///
617
+ /// ```
618
+ /// use anveesa::export_conversation;
619
+ /// use anveesa::provider::{ChatMessage, ChatRole};
620
+ /// use std::path::Path;
621
+ ///
622
+ /// let history = vec![
623
+ /// ChatMessage { role: ChatRole::User, content: "hello".into() },
624
+ /// ChatMessage { role: ChatRole::Assistant, content: "hi".into() },
625
+ /// ];
626
+ /// let path = Path::new("/tmp/anveesa-export-test.md");
627
+ /// export_conversation(path, &history).ok();
628
+ /// ```
629
+ pub fn export_conversation(path: &std::path::Path, history: &[ChatMessage]) -> Result<()> {
630
+ let mut out = String::new();
631
+ for msg in history {
632
+ match msg.role {
633
+ ChatRole::User => {
634
+ out.push_str("## You\n\n");
635
+ out.push_str(&msg.content);
636
+ out.push_str("\n\n");
637
+ }
638
+ ChatRole::Assistant => {
639
+ out.push_str("## Assistant\n\n");
640
+ out.push_str(&msg.content);
641
+ out.push_str("\n\n");
642
+ }
643
+ }
644
+ }
645
+ fs::write(path, out.trim_end()).with_context(|| format!("failed to write {}", path.display()))
646
+ }
647
+
648
+ fn list_providers() -> Result<()> {
649
+ let config = AppConfig::load()?;
650
+ let is_tty = io::stdout().is_terminal();
651
+
652
+ if !is_tty {
653
+ for (name, provider) in &config.providers {
654
+ let is_default = config.default_provider.as_deref() == Some(name.as_str());
655
+ let model = provider.default_model().unwrap_or("-");
656
+ println!(
657
+ "{} {name} {model} {}",
658
+ if is_default { "*" } else { " " },
659
+ provider.kind()
660
+ );
661
+ }
662
+ return Ok(());
663
+ }
664
+
665
+ println!();
666
+ for (name, provider) in &config.providers {
667
+ let is_default = config.default_provider.as_deref() == Some(name.as_str());
668
+ let model = provider.default_model().unwrap_or("-");
669
+ let default_tag = if is_default {
670
+ " \x1b[1;32m● default\x1b[0m"
671
+ } else {
672
+ ""
673
+ };
674
+ println!(
675
+ " \x1b[1m{name}\x1b[0m \x1b[2m{model} {}\x1b[0m{default_tag}",
676
+ provider.kind()
677
+ );
678
+ }
679
+ println!();
680
+ Ok(())
681
+ }
682
+
683
+ fn run_config(command: ConfigCommand) -> Result<()> {
684
+ match command {
685
+ ConfigCommand::Init { force } => {
686
+ let path = init_config(force)?;
687
+ println!("created {}", print_path(&path));
688
+ Ok(())
689
+ }
690
+ ConfigCommand::SetModel { provider, model } => {
691
+ let (path, provider_name) = set_default_model(provider.as_deref(), model)?;
692
+ println!(
693
+ "set default model for {provider_name} in {}",
694
+ print_path(&path)
695
+ );
696
+ Ok(())
697
+ }
698
+ ConfigCommand::SetProvider { provider } => {
699
+ let path = set_default_provider(provider.clone())?;
700
+ println!(
701
+ "set default provider to {provider} in {}",
702
+ print_path(&path)
703
+ );
704
+ Ok(())
705
+ }
706
+ ConfigCommand::Path => {
707
+ println!("{}", print_path(&config_path()?));
708
+ Ok(())
709
+ }
710
+ ConfigCommand::Show => {
711
+ let config = AppConfig::load()?;
712
+ println!("{}", toml::to_string_pretty(&config)?);
713
+ Ok(())
714
+ }
715
+ }
716
+ }
717
+
718
+ fn build_prompt(prompt_parts: Vec<String>, force_stdin: bool) -> Result<String> {
719
+ use std::io::Read;
720
+ let mut prompt = prompt_parts.join(" ");
721
+
722
+ if force_stdin || (prompt.is_empty() && !std::io::stdin().is_terminal()) {
723
+ let mut stdin = String::new();
724
+ std::io::stdin()
725
+ .read_to_string(&mut stdin)
726
+ .context("failed to read stdin")?;
727
+
728
+ prompt = match (prompt.trim().is_empty(), stdin.trim().is_empty()) {
729
+ (true, true) => String::new(),
730
+ (true, false) => stdin,
731
+ (false, true) => prompt,
732
+ (false, false) => format!("{prompt}\n\n{stdin}"),
733
+ };
734
+ }
735
+
736
+ if prompt.trim().is_empty() {
737
+ anyhow::bail!("prompt is empty; pass text arguments or pipe input with --stdin")
738
+ }
739
+
740
+ Ok(prompt)
741
+ }
742
+
743
+ fn one_shot_policy(auto_approve: bool, stdin_is_terminal: bool) -> ApprovalPolicy {
744
+ if auto_approve {
745
+ ApprovalPolicy::Allow
746
+ } else if stdin_is_terminal {
747
+ ApprovalPolicy::Prompt
748
+ } else {
749
+ ApprovalPolicy::Deny
750
+ }
751
+ }
752
+
753
+ /// Return the current Unix timestamp in seconds.
754
+ pub fn unix_now() -> u64 {
755
+ std::time::SystemTime::now()
756
+ .duration_since(std::time::UNIX_EPOCH)
757
+ .unwrap_or_default()
758
+ .as_secs()
759
+ }
760
+
761
+ #[cfg(test)]
762
+ mod tests {
763
+ use super::*;
764
+
765
+ #[test]
766
+ fn build_prompt_joins_parts() {
767
+ let prompt = build_prompt(vec!["hello".into(), "world".into()], false).unwrap();
768
+ assert_eq!(prompt, "hello world");
769
+ }
770
+
771
+ #[test]
772
+ fn one_shot_policy_prompts_only_when_terminal_can_answer() {
773
+ assert_eq!(one_shot_policy(true, false), ApprovalPolicy::Allow);
774
+ assert_eq!(one_shot_policy(false, true), ApprovalPolicy::Prompt);
775
+ assert_eq!(one_shot_policy(false, false), ApprovalPolicy::Deny);
776
+ }
777
+ }