1use std::fs;
2use std::io::{self, IsTerminal, Write};
3use std::path::Path;
4
5use crate::cli::ConfigCommand;
6use crate::config::{self, Config};
7use crate::doctor;
8use crate::error::ViaError;
9
10pub fn run(path_override: Option<&Path>, command: ConfigCommand) -> Result<(), ViaError> {
11 let path = config::resolve_path(path_override)?;
12
13 match command {
14 ConfigCommand::Configure => configure(&path),
15 ConfigCommand::Path => {
16 println!("{}", path.display());
17 Ok(())
18 }
19 ConfigCommand::Doctor { service } => {
20 if !path.exists() {
21 print_missing_config(&path);
22 return Err(ViaError::ConfigNotFound(
23 "run `via config` in an interactive terminal to create one".to_owned(),
24 ));
25 }
26
27 let config = Config::load(Some(&path))?;
28 doctor::run(&config, service.as_deref())
29 }
30 }
31}
32
33fn configure(path: &Path) -> Result<(), ViaError> {
34 if path.exists() {
35 println!("via config: {}", path.display());
36 println!("Run `via config doctor` to check providers, secrets, and delegated tools.");
37 return Ok(());
38 }
39
40 if !io::stdin().is_terminal() {
41 print_missing_config(path);
42 return Err(ViaError::ConfigNotFound(
43 "run `via config` in an interactive terminal to create one".to_owned(),
44 ));
45 }
46
47 println!("No via config found.");
48 println!();
49 println!("via can create one at:");
50 println!(" {}", path.display());
51 println!();
52
53 match prompt_choice(
54 "What do you want to configure?",
55 &[
56 "A service with 1Password",
57 "Empty config",
58 "Print config path only",
59 ],
60 1,
61 )? {
62 1 => write_config(path, &build_service_config(prompt_service_setup()?)),
63 2 => write_config(path, empty_config()),
64 3 => {
65 println!("{}", path.display());
66 Ok(())
67 }
68 _ => unreachable!("prompt_choice only returns listed choices"),
69 }
70}
71
72fn write_config(path: &Path, contents: &str) -> Result<(), ViaError> {
73 if let Some(parent) = path.parent() {
74 fs::create_dir_all(parent)?;
75 }
76 fs::write(path, contents)?;
77 println!("created via config: {}", path.display());
78 println!("Run `via config doctor` to check the setup.");
79 Ok(())
80}
81
82struct ServiceSetup {
83 service_name: String,
84 hint: String,
85 secret_name: String,
86 secret_reference: String,
87 private_key_secret_name: Option<String>,
88 private_key_secret_reference: Option<String>,
89 rest: Option<RestSetup>,
90 delegated: Option<DelegatedSetup>,
91}
92
93struct RestSetup {
94 command_name: String,
95 base_url: String,
96 method_default: String,
97 auth: RestAuthSetup,
98}
99
100enum RestAuthSetup {
101 Bearer,
102 GitHubApp,
103 OAuth,
104}
105
106struct DelegatedSetup {
107 command_name: String,
108 program: String,
109 env_var: String,
110 check_args: Vec<String>,
111}
112
113fn prompt_service_setup() -> Result<ServiceSetup, ViaError> {
114 let service_name = prompt_required("Service name", None)?;
115 let hint = prompt_optional("Example command hint")?;
116 let secret_name = prompt_required("Secret name in via config", Some("token"))?;
117 let secret_reference = prompt_secret_reference()?;
118 let mode = prompt_service_mode()?;
119 let rest = prompt_optional_rest_setup(mode)?;
120 let (private_key_secret_name, private_key_secret_reference) =
121 prompt_optional_private_key(rest.as_ref())?;
122 let delegated = prompt_optional_delegated_setup(mode)?;
123
124 Ok(ServiceSetup {
125 service_name,
126 hint,
127 secret_name,
128 secret_reference,
129 private_key_secret_name,
130 private_key_secret_reference,
131 rest,
132 delegated,
133 })
134}
135
136fn prompt_service_mode() -> Result<usize, ViaError> {
137 prompt_choice(
138 "How should via run this service?",
139 &["REST API", "Trusted CLI", "Both"],
140 1,
141 )
142}
143
144fn prompt_optional_rest_setup(mode: usize) -> Result<Option<RestSetup>, ViaError> {
145 if mode_uses_rest(mode) {
146 Ok(Some(prompt_rest_setup()?))
147 } else {
148 Ok(None)
149 }
150}
151
152fn prompt_optional_private_key(
153 rest: Option<&RestSetup>,
154) -> Result<(Option<String>, Option<String>), ViaError> {
155 if rest.is_some_and(rest_uses_github_app) {
156 prompt_private_key_secret()
157 } else {
158 Ok((None, None))
159 }
160}
161
162fn prompt_optional_delegated_setup(mode: usize) -> Result<Option<DelegatedSetup>, ViaError> {
163 if mode_uses_delegated(mode) {
164 Ok(Some(prompt_delegated_setup()?))
165 } else {
166 Ok(None)
167 }
168}
169
170fn mode_uses_rest(mode: usize) -> bool {
171 mode == 1 || mode == 3
172}
173
174fn mode_uses_delegated(mode: usize) -> bool {
175 mode == 2 || mode == 3
176}
177
178fn rest_uses_github_app(rest: &RestSetup) -> bool {
179 matches!(rest.auth, RestAuthSetup::GitHubApp)
180}
181
182fn prompt_private_key_secret() -> Result<(Option<String>, Option<String>), ViaError> {
183 println!();
184 println!("GitHub App private key");
185 let name = prompt_required("Private key secret name in via config", Some("private_key"))?;
186 let reference = prompt_secret_reference()?;
187 Ok((Some(name), Some(reference)))
188}
189
190fn prompt_secret_reference() -> Result<String, ViaError> {
191 loop {
192 println!("1Password secret reference:");
193 println!(" Example: op://Private/Service/token");
194 let value = prompt_required("Reference", None)?;
195 if value.starts_with("op://") {
196 return Ok(value);
197 }
198 println!("Secret references must start with `op://`.");
199 }
200}
201
202fn prompt_rest_setup() -> Result<RestSetup, ViaError> {
203 println!();
204 println!("REST API capability");
205 let (command_name, base_url, method_default) = prompt_rest_fields()?;
206 let auth = prompt_rest_auth_setup()?;
207
208 Ok(RestSetup {
209 command_name,
210 base_url,
211 method_default,
212 auth,
213 })
214}
215
216fn prompt_rest_fields() -> Result<(String, String, String), ViaError> {
217 Ok((
218 prompt_required("Capability name", Some("api"))?,
219 prompt_required("Base URL", None)?,
220 prompt_required("Default HTTP method", Some("GET"))?,
221 ))
222}
223
224fn prompt_rest_auth_setup() -> Result<RestAuthSetup, ViaError> {
225 rest_auth_setup_from_choice(prompt_choice(
226 "How should REST authenticate?",
227 &[
228 "Bearer token",
229 "GitHub App credential bundle",
230 "OAuth credential bundle (prefer client_credentials for bots)",
231 ],
232 1,
233 )?)
234}
235
236fn rest_auth_setup_from_choice(choice: usize) -> Result<RestAuthSetup, ViaError> {
237 match choice {
238 1 => Ok(RestAuthSetup::Bearer),
239 2 => Ok(RestAuthSetup::GitHubApp),
240 3 => Ok(RestAuthSetup::OAuth),
241 _ => Err(ViaError::InvalidConfig(format!(
242 "unsupported REST auth choice {choice}"
243 ))),
244 }
245}
246
247fn prompt_delegated_setup() -> Result<DelegatedSetup, ViaError> {
248 println!();
249 println!("Trusted CLI capability");
250 let program = prompt_required("Program", None)?;
251 let default_command = program.clone();
252 let command_name = prompt_required("Capability name", Some(&default_command))?;
253 let env_var = prompt_required("Environment variable to inject", Some("TOKEN"))?;
254 let check = prompt_required("Check command args", Some("--version"))?;
255
256 Ok(DelegatedSetup {
257 command_name,
258 program,
259 env_var,
260 check_args: split_args(&check),
261 })
262}
263
264fn prompt_choice(prompt: &str, choices: &[&str], default: usize) -> Result<usize, ViaError> {
265 loop {
266 println!("{prompt}");
267 for (index, choice) in choices.iter().enumerate() {
268 println!(" {}. {choice}", index + 1);
269 }
270
271 let raw = prompt_optional(&format!("Choice [{default}]"))?;
272 let choice = if raw.is_empty() {
273 default
274 } else {
275 match raw.parse::<usize>() {
276 Ok(choice) => choice,
277 Err(_) => {
278 println!("Enter a number from 1 to {}.", choices.len());
279 continue;
280 }
281 }
282 };
283
284 if (1..=choices.len()).contains(&choice) {
285 return Ok(choice);
286 }
287 println!("Enter a number from 1 to {}.", choices.len());
288 }
289}
290
291fn prompt_required(prompt: &str, default: Option<&str>) -> Result<String, ViaError> {
292 loop {
293 let label = match default {
294 Some(default) => format!("{prompt} [{default}]"),
295 None => prompt.to_owned(),
296 };
297 let value = prompt_optional(&label)?;
298 if !value.is_empty() {
299 return Ok(value);
300 }
301 if let Some(default) = default {
302 return Ok(default.to_owned());
303 }
304 println!("This value is required.");
305 }
306}
307
308fn prompt_optional(prompt: &str) -> Result<String, ViaError> {
309 print!("{prompt}: ");
310 io::stdout().flush()?;
311
312 let mut value = String::new();
313 io::stdin().read_line(&mut value)?;
314 Ok(value.trim().to_owned())
315}
316
317fn build_service_config(setup: ServiceSetup) -> String {
318 let mut output = String::new();
319 output.push_str("version = 1\n\n");
320 output.push_str("[providers.onepassword]\n");
321 output.push_str("type = \"1password\"\n");
322 output.push_str("cache = \"daemon\"\n\n");
323 output.push_str(&format!("[services.{}]\n", toml_key(&setup.service_name)));
324 output.push_str(&format!(
325 "description = {}\n",
326 toml_string(&format!("{} access", setup.service_name))
327 ));
328 if !setup.hint.is_empty() {
329 output.push_str(&format!("hint = {}\n", toml_string(&setup.hint)));
330 }
331 output.push_str("provider = \"onepassword\"\n\n");
332 output.push_str(&format!(
333 "[services.{}.secrets]\n",
334 toml_key(&setup.service_name)
335 ));
336 output.push_str(&format!(
337 "{} = {}\n\n",
338 toml_key(&setup.secret_name),
339 toml_string(&setup.secret_reference)
340 ));
341 if let (Some(name), Some(reference)) = (
342 &setup.private_key_secret_name,
343 &setup.private_key_secret_reference,
344 ) {
345 output.truncate(output.trim_end_matches('\n').len());
346 output.push('\n');
347 output.push_str(&format!(
348 "{} = {}\n\n",
349 toml_key(name),
350 toml_string(reference)
351 ));
352 }
353
354 if let Some(rest) = setup.rest {
355 output.push_str(&format!(
356 "[services.{}.commands.{}]\n",
357 toml_key(&setup.service_name),
358 toml_key(&rest.command_name)
359 ));
360 output
361 .push_str("description = \"Call the configured REST API. Prefer this for agents.\"\n");
362 output.push_str("mode = \"rest\"\n");
363 output.push_str(&format!("base_url = {}\n", toml_string(&rest.base_url)));
364 output.push_str(&format!(
365 "method_default = {}\n\n",
366 toml_string(&rest.method_default)
367 ));
368 output.push_str(&format!(
369 "[services.{}.commands.{}.auth]\n",
370 toml_key(&setup.service_name),
371 toml_key(&rest.command_name)
372 ));
373 match rest.auth {
374 RestAuthSetup::Bearer => {
375 output.push_str("type = \"bearer\"\n");
376 output.push_str(&format!("secret = {}\n\n", toml_string(&setup.secret_name)));
377 }
378 RestAuthSetup::GitHubApp => {
379 let private_key = setup
380 .private_key_secret_name
381 .as_deref()
382 .unwrap_or("private_key");
383 output.push_str("type = \"github_app\"\n");
384 output.push_str(&format!(
385 "credential = {}\n",
386 toml_string(&setup.secret_name)
387 ));
388 output.push_str(&format!("private_key = {}\n\n", toml_string(private_key)));
389 }
390 RestAuthSetup::OAuth => {
391 output.push_str("type = \"oauth\"\n");
392 output.push_str(&format!(
393 "credential = {}\n\n",
394 toml_string(&setup.secret_name)
395 ));
396 }
397 }
398 }
399
400 if let Some(delegated) = setup.delegated {
401 output.push_str(&format!(
402 "[services.{}.commands.{}]\n",
403 toml_key(&setup.service_name),
404 toml_key(&delegated.command_name)
405 ));
406 output
407 .push_str("description = \"Run the configured trusted CLI with a secret injected.\"\n");
408 output.push_str("mode = \"delegated\"\n");
409 output.push_str(&format!("program = {}\n", toml_string(&delegated.program)));
410 output.push_str(&format!(
411 "check = {}\n\n",
412 toml_array(&delegated.check_args)
413 ));
414 output.push_str(&format!(
415 "[services.{}.commands.{}.inject.env.{}]\n",
416 toml_key(&setup.service_name),
417 toml_key(&delegated.command_name),
418 toml_key(&delegated.env_var)
419 ));
420 output.push_str(&format!("secret = {}\n", toml_string(&setup.secret_name)));
421 }
422
423 output
424}
425
426fn empty_config() -> &'static str {
427 r#"version = 1
428
429[providers.onepassword]
430type = "1password"
431cache = "daemon"
432
433"#
434}
435
436fn split_args(value: &str) -> Vec<String> {
437 value.split_whitespace().map(str::to_owned).collect()
438}
439
440fn toml_key(value: &str) -> String {
441 toml_string(value)
442}
443
444fn toml_array(values: &[String]) -> String {
445 let values = values
446 .iter()
447 .map(|value| toml_string(value))
448 .collect::<Vec<_>>()
449 .join(", ");
450 format!("[{values}]")
451}
452
453fn toml_string(value: &str) -> String {
454 let mut escaped = String::new();
455 for character in value.chars() {
456 match character {
457 '\\' => escaped.push_str("\\\\"),
458 '"' => escaped.push_str("\\\""),
459 '\n' => escaped.push_str("\\n"),
460 '\r' => escaped.push_str("\\r"),
461 '\t' => escaped.push_str("\\t"),
462 other => escaped.push(other),
463 }
464 }
465 format!("\"{escaped}\"")
466}
467
468fn print_missing_config(path: &Path) {
469 println!("No via config found at:");
470 println!(" {}", path.display());
471 println!();
472 println!("Human setup:");
473 println!(" Run `via config` in an interactive terminal to create one.");
474 println!();
475 println!("Agent guidance:");
476 println!(" Ask the user to run `via config`, then rerun `via config doctor`.");
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482
483 #[test]
484 fn builds_generic_rest_config() {
485 let config = build_service_config(ServiceSetup {
486 service_name: "gitlab".to_owned(),
487 hint: "via gitlab api /projects".to_owned(),
488 secret_name: "token".to_owned(),
489 secret_reference: "op://Private/GitLab/token".to_owned(),
490 private_key_secret_name: None,
491 private_key_secret_reference: None,
492 rest: Some(RestSetup {
493 command_name: "api".to_owned(),
494 base_url: "https://gitlab.example.com/api/v4".to_owned(),
495 method_default: "GET".to_owned(),
496 auth: RestAuthSetup::Bearer,
497 }),
498 delegated: None,
499 });
500
501 assert!(config.contains("[services.\"gitlab\"]"));
502 assert!(config.contains("hint = \"via gitlab api /projects\""));
503 assert!(config.contains("cache = \"daemon\""));
504 assert!(config.contains("base_url = \"https://gitlab.example.com/api/v4\""));
505 assert!(Config::from_toml_str(&config).is_ok());
506 }
507
508 #[test]
509 fn builds_github_app_rest_config() {
510 let config = build_service_config(ServiceSetup {
511 service_name: "github".to_owned(),
512 hint: String::new(),
513 secret_name: "app".to_owned(),
514 secret_reference: "op://Private/Example GitHub App/metadata".to_owned(),
515 private_key_secret_name: Some("private_key".to_owned()),
516 private_key_secret_reference: Some(
517 "op://Private/Example GitHub App/github-app.private-key.pem".to_owned(),
518 ),
519 rest: Some(RestSetup {
520 command_name: "api".to_owned(),
521 base_url: "https://api.github.com".to_owned(),
522 method_default: "GET".to_owned(),
523 auth: RestAuthSetup::GitHubApp,
524 }),
525 delegated: None,
526 });
527
528 assert!(config.contains("type = \"github_app\""));
529 assert!(config.contains("cache = \"daemon\""));
530 assert!(config.contains("credential = \"app\""));
531 assert!(config.contains("private_key = \"private_key\""));
532 assert!(Config::from_toml_str(&config).is_ok());
533 }
534
535 #[test]
536 fn builds_oauth_rest_config() {
537 let config = build_service_config(ServiceSetup {
538 service_name: "linear".to_owned(),
539 hint: "via linear api POST /graphql --json '{\"query\":\"{ viewer { id name } }\"}'"
540 .to_owned(),
541 secret_name: "oauth".to_owned(),
542 secret_reference: "op://Private/Linear/oauth".to_owned(),
543 private_key_secret_name: None,
544 private_key_secret_reference: None,
545 rest: Some(RestSetup {
546 command_name: "api".to_owned(),
547 base_url: "https://api.linear.app".to_owned(),
548 method_default: "GET".to_owned(),
549 auth: RestAuthSetup::OAuth,
550 }),
551 delegated: None,
552 });
553
554 assert!(config.contains("type = \"oauth\""));
555 assert!(config.contains(
556 "hint = \"via linear api POST /graphql --json '{\\\"query\\\":\\\"{ viewer { id name } }\\\"}'\""
557 ));
558 assert!(config.contains("credential = \"oauth\""));
559 assert!(Config::from_toml_str(&config).is_ok());
560 }
561
562 #[test]
563 fn maps_rest_auth_setup_choices() {
564 assert!(matches!(
565 rest_auth_setup_from_choice(1).unwrap(),
566 RestAuthSetup::Bearer
567 ));
568 assert!(matches!(
569 rest_auth_setup_from_choice(2).unwrap(),
570 RestAuthSetup::GitHubApp
571 ));
572 assert!(matches!(
573 rest_auth_setup_from_choice(3).unwrap(),
574 RestAuthSetup::OAuth
575 ));
576 assert!(rest_auth_setup_from_choice(4).is_err());
577 }
578
579 #[test]
580 fn builds_generic_delegated_config() {
581 let config = build_service_config(ServiceSetup {
582 service_name: "deploy tool".to_owned(),
583 hint: String::new(),
584 secret_name: "api token".to_owned(),
585 secret_reference: "op://Private/Deploy/token".to_owned(),
586 private_key_secret_name: None,
587 private_key_secret_reference: None,
588 rest: None,
589 delegated: Some(DelegatedSetup {
590 command_name: "cli".to_owned(),
591 program: "deployctl".to_owned(),
592 env_var: "DEPLOY_TOKEN".to_owned(),
593 check_args: vec!["--version".to_owned()],
594 }),
595 });
596
597 assert!(config.contains("[services.\"deploy tool\"]"));
598 assert!(config
599 .contains("[services.\"deploy tool\".commands.\"cli\".inject.env.\"DEPLOY_TOKEN\"]"));
600 assert!(Config::from_toml_str(&config).is_ok());
601 }
602
603 #[test]
604 fn escapes_toml_strings() {
605 assert_eq!(toml_string("a\"b\\c"), "\"a\\\"b\\\\c\"");
606 }
607}