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