use std::io::{self, BufRead, IsTerminal, Write};
use std::path::PathBuf;
use clap::{Args, ValueEnum};
use tga::core::config::Config;
use super::install_flags::{plan_from_answers, resolve_plan, Answers};
use super::install_plan::{render_yaml, Credential, InstallPlan, JiraSettings, LinearSettings};
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)]
pub enum InstallHost {
#[default]
Local,
Github,
Bitbucket,
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)]
pub enum InstallPm {
#[default]
None,
Github,
Jira,
Linear,
}
#[derive(Args, Debug, Clone)]
#[command(
about = "Configuration wizard for first-time setup — interactive or flag-driven.",
long_about = "Generate a config.yaml bootstrapped with the minimal required fields.\n\n\
On a terminal with no flags this walks through a series of prompts; press\n\
<enter> at each to accept the default shown in brackets.\n\n\
Given --host and --pm (or with stdin not a terminal) it runs non-interactively\n\
from flags and environment variables instead, and never prompts. With --host\n\
github and --org — or --host bitbucket and --workspace — it discovers the\n\
repositories over the provider's API and writes them into the generated\n\
config.\n\n\
Flag values win over environment variables. A credential taken from the\n\
environment is written to the config as a ${VAR} reference, not as the secret.",
after_help = "EXAMPLES:\n\
# First-time setup on a terminal (writes config.yaml in the current directory)\n\
tga install\n\n\
# Zero-config org audit, no terminal required\n\
GITHUB_TOKEN=ghp_… tga install --host github --org acme --pm github\n\n\
# Bitbucket Cloud — the workspace's repositories are discovered over the API\n\
BITBUCKET_TOKEN=… tga install --host bitbucket --workspace acme --pm jira \\\n\
--jira-url https://acme.atlassian.net --jira-user me@acme.com\n\n\
# Write config to a custom path, overwriting any existing file\n\
tga install --output /etc/tga/config.yaml --force\n\n\
TIPS:\n\
- After running install, validate the config with `tga collect --validate-only`.\n\
- Re-run at any time to regenerate the config from scratch."
)]
pub struct InstallArgs {
#[arg(short, long, default_value = "config.yaml")]
pub output: PathBuf,
#[arg(long, default_value_t = false)]
pub force: bool,
#[arg(long, default_value_t = false)]
pub(crate) non_interactive: bool,
#[arg(long, value_enum)]
pub(crate) host: Option<InstallHost>,
#[arg(long, value_name = "ORG")]
pub(crate) org: Option<String>,
#[arg(long, value_name = "WORKSPACE")]
pub(crate) workspace: Option<String>,
#[arg(long, value_name = "OWNER/NAME")]
pub(crate) repo: Vec<String>,
#[arg(long, value_name = "PATH")]
pub(crate) repo_path: Vec<PathBuf>,
#[arg(long, value_name = "DIR", default_value = "./repos")]
pub(crate) repo_cache: PathBuf,
#[arg(long, value_name = "TOKEN")]
pub(crate) host_token: Option<String>,
#[arg(long, value_enum)]
pub(crate) pm: Option<InstallPm>,
#[arg(long, value_name = "URL")]
pub(crate) jira_url: Option<String>,
#[arg(long, value_name = "EMAIL")]
pub(crate) jira_user: Option<String>,
#[arg(long, value_name = "TOKEN")]
pub(crate) jira_token: Option<String>,
#[arg(long, value_name = "KEY")]
pub(crate) linear_api_key: Option<String>,
#[arg(long, value_name = "TEAM")]
pub(crate) linear_team: Vec<String>,
#[arg(long, value_name = "DIR", default_value = "./tga-output")]
pub(crate) output_dir: String,
#[arg(long, value_name = "PROVIDER", default_value = "none")]
pub(crate) llm_provider: String,
#[arg(long, value_name = "KEY")]
pub(crate) llm_api_key: Option<String>,
}
impl InstallArgs {
#[must_use]
pub fn wants_non_interactive(&self) -> bool {
self.non_interactive
|| self.host.is_some()
|| self.pm.is_some()
|| self.org.is_some()
|| self.workspace.is_some()
|| !self.repo.is_empty()
|| !self.repo_path.is_empty()
|| self.host_token.is_some()
|| self.jira_url.is_some()
|| self.jira_user.is_some()
|| self.jira_token.is_some()
|| self.linear_api_key.is_some()
|| !self.linear_team.is_empty()
|| self.llm_api_key.is_some()
}
}
pub async fn run(_config: Config, args: InstallArgs) -> anyhow::Result<()> {
if args.output.exists() && !args.force {
anyhow::bail!(
"{} already exists. Re-run with --force to overwrite.",
args.output.display()
);
}
let plan = if args.wants_non_interactive() || !io::stdin().is_terminal() {
resolve_plan(&args).await?
} else {
let stdin = io::stdin();
let mut input = stdin.lock();
let answers = collect_answers(&mut input, &args)?;
plan_from_answers(answers).await?
};
write_plan(&plan, &args.output)
}
fn write_plan(plan: &InstallPlan, output: &std::path::Path) -> anyhow::Result<()> {
let output_dir_path = PathBuf::from(&plan.output_dir);
if let Err(e) = std::fs::create_dir_all(&output_dir_path) {
anyhow::bail!(
"cannot create output directory {}: {e}",
output_dir_path.display()
);
}
for note in &plan.notes {
eprintln!(" note: {note}");
}
std::fs::write(output, render_yaml(plan))?;
println!(
"\nConfig written to {}. Run: tga analyze --config {}",
output.display(),
output.display()
);
Ok(())
}
fn collect_answers<R: BufRead>(reader: &mut R, args: &InstallArgs) -> anyhow::Result<Answers> {
println!("tga install — interactive configuration wizard");
println!("Press <enter> to accept the default shown in [brackets].\n");
let mut answers = Answers {
repo_cache: args.repo_cache.clone(),
..Answers::default()
};
let host = prompt(
reader,
"Code host — choose one: local / github / bitbucket",
Some("local"),
)?;
answers.host = match host.to_lowercase().as_str() {
"github" => InstallHost::Github,
"bitbucket" => InstallHost::Bitbucket,
_ => InstallHost::Local,
};
prompt_host(reader, &mut answers)?;
prompt_pm(reader, &mut answers)?;
answers.output_dir = prompt(reader, "Output directory", Some("./tga-output"))?;
let llm_provider = prompt(
reader,
"LLM provider — choose one: none / openai / openrouter",
Some("none"),
)?
.to_lowercase();
answers.llm_api_key = if llm_provider == "openai" || llm_provider == "openrouter" {
prompt_optional(
reader,
&format!("{llm_provider} API key (leave blank to set later via env var)"),
)?
.map(Credential::literal)
} else {
None
};
answers.llm_provider = llm_provider;
Ok(answers)
}
fn prompt_host<R: BufRead>(reader: &mut R, answers: &mut Answers) -> anyhow::Result<()> {
match answers.host {
InstallHost::Local => {
answers.repo_paths = prompt_paths(reader)?;
}
InstallHost::Github => {
answers.org = prompt_optional(
reader,
"GitHub org to discover repositories from (leave blank to list paths instead)",
)?;
if answers.org.is_none() {
answers.repo_paths = prompt_paths(reader)?;
}
answers.host_token =
prompt_optional(reader, "GitHub token (optional, leave blank to skip)")?
.map(Credential::literal);
}
InstallHost::Bitbucket => {
answers.workspace = Some(prompt(reader, "Bitbucket Cloud workspace", None)?);
println!(" The workspace's repositories are discovered over the API.");
answers.repo_slugs = split_list(
&prompt_optional(
reader,
"Extra Bitbucket repositories (comma-separated <workspace>/<slug>, optional)",
)?
.unwrap_or_default(),
);
answers.host_token =
prompt_optional(reader, "Bitbucket token (optional, leave blank to skip)")?
.map(Credential::literal);
}
}
Ok(())
}
fn prompt_pm<R: BufRead>(reader: &mut R, answers: &mut Answers) -> anyhow::Result<()> {
let pm = prompt(
reader,
"Project-management system — choose one: none / github / jira / linear",
Some("none"),
)?;
answers.pm = match pm.to_lowercase().as_str() {
"github" => InstallPm::Github,
"jira" => InstallPm::Jira,
"linear" => InstallPm::Linear,
_ => InstallPm::None,
};
match answers.pm {
InstallPm::Jira => {
let url = prompt(reader, "JIRA URL", None)?;
let username = prompt(reader, "JIRA username/email", None)?;
let token = prompt(reader, "JIRA API token", None)?;
answers.jira = Some(JiraSettings {
url,
username,
token: Credential::literal(token),
});
}
InstallPm::Linear => {
let api_key = prompt(reader, "Linear API key", None)?;
let teams = prompt_optional(
reader,
"Linear team keys (comma-separated, blank for every visible team)",
)?;
answers.linear = Some(LinearSettings {
api_key: Credential::literal(api_key),
team_keys: teams.as_deref().map(split_list).unwrap_or_default(),
});
}
InstallPm::None | InstallPm::Github => {}
}
Ok(())
}
fn prompt_paths<R: BufRead>(reader: &mut R) -> anyhow::Result<Vec<PathBuf>> {
let raw = prompt(
reader,
"Path(s) to git repository (comma-separated for multiple)",
None,
)?;
let paths: Vec<PathBuf> = split_list(&raw).into_iter().map(PathBuf::from).collect();
if paths.is_empty() {
anyhow::bail!("at least one repository path is required");
}
for p in &paths {
if !p.exists() {
eprintln!(
" warning: {} does not exist (continuing anyway — fix before running `tga analyze`)",
p.display()
);
}
}
Ok(paths)
}
pub(super) fn split_list(raw: &str) -> Vec<String> {
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}
fn prompt<R: BufRead>(
reader: &mut R,
prompt: &str,
default: Option<&str>,
) -> anyhow::Result<String> {
let label = match default {
Some(d) => format!("{prompt} [{d}]: "),
None => format!("{prompt}: "),
};
let mut stdout = io::stdout();
stdout.write_all(label.as_bytes())?;
stdout.flush()?;
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 {
if let Some(d) = default {
return Ok(d.to_string());
}
anyhow::bail!("unexpected EOF while reading input for: {prompt}");
}
let trimmed = line.trim().to_string();
if trimmed.is_empty() {
if let Some(d) = default {
return Ok(d.to_string());
}
anyhow::bail!("a value is required for: {prompt}");
}
Ok(trimmed)
}
fn prompt_optional<R: BufRead>(reader: &mut R, prompt: &str) -> anyhow::Result<Option<String>> {
let label = format!("{prompt}: ");
let mut stdout = io::stdout();
stdout.write_all(label.as_bytes())?;
stdout.flush()?;
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 {
return Ok(None);
}
let trimmed = line.trim();
if trimmed.is_empty() {
Ok(None)
} else {
Ok(Some(trimmed.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::install_flags::args_for_tests;
#[test]
fn answer_flags_select_the_flag_path() {
assert!(!args_for_tests(&["tga"]).wants_non_interactive());
assert!(args_for_tests(&["tga", "--non-interactive"]).wants_non_interactive());
assert!(args_for_tests(&["tga", "--org", "acme"]).wants_non_interactive());
assert!(args_for_tests(&["tga", "--repo-path", "/tmp/r"]).wants_non_interactive());
assert!(args_for_tests(&["tga", "--linear-team", "ENG"]).wants_non_interactive());
assert!(!args_for_tests(&["tga", "--output", "x.yaml", "--force"]).wants_non_interactive());
}
#[tokio::test]
async fn wizard_and_flag_paths_render_identical_config() {
let script = "local\n\
/tmp/widget\n\
jira\n\
https://acme.atlassian.net\n\
me@acme.com\n\
jira-token\n\
./out\n\
none\n";
let mut input = script.as_bytes();
let wizard_args = args_for_tests(&["tga"]);
let wizard_answers = collect_answers(&mut input, &wizard_args).expect("wizard completes");
let wizard_plan = plan_from_answers(wizard_answers).await.expect("plan");
let flag_args = args_for_tests(&[
"tga",
"--host",
"local",
"--repo-path",
"/tmp/widget",
"--pm",
"jira",
"--jira-url",
"https://acme.atlassian.net",
"--jira-user",
"me@acme.com",
"--jira-token",
"jira-token",
"--output-dir",
"./out",
]);
let flag_plan = resolve_plan(&flag_args).await.expect("flag path resolves");
assert_eq!(wizard_plan, flag_plan, "plans diverged");
assert_eq!(
render_yaml(&wizard_plan),
render_yaml(&flag_plan),
"rendered config diverged"
);
}
#[tokio::test]
async fn wizard_collects_linear_answers() {
let script = "local\n/tmp/widget\nlinear\nlin_key\nENG, OPS\n./out\nnone\n";
let mut input = script.as_bytes();
let answers =
collect_answers(&mut input, &args_for_tests(&["tga"])).expect("wizard completes");
let yaml = render_yaml(&plan_from_answers(answers).await.expect("plan"));
assert!(yaml.contains("linear:"), "{yaml}");
assert!(yaml.contains("api_key: \"lin_key\""), "{yaml}");
assert!(yaml.contains("team_keys: [\"ENG\", \"OPS\"]"), "{yaml}");
}
#[tokio::test]
async fn wizard_collects_bitbucket_workspace_and_repos() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repositories/acme"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [{"full_name": "acme/sprocket", "slug": "sprocket"}],
})))
.mount(&server)
.await;
let script = "bitbucket\nacme\nacme/widget, acme/gadget\nbb-token\nnone\n./out\nnone\n";
let mut input = script.as_bytes();
let mut answers =
collect_answers(&mut input, &args_for_tests(&["tga"])).expect("wizard completes");
answers.bitbucket_api_base = Some(server.uri());
let plan = plan_from_answers(answers).await.expect("plan");
let yaml = render_yaml(&plan);
assert!(yaml.contains("workspaces: [\"acme\"]"), "{yaml}");
assert!(yaml.contains("name: \"widget\""), "{yaml}");
assert!(yaml.contains("name: \"gadget\""), "{yaml}");
assert!(
yaml.contains("name: \"sprocket\""),
"the discovered repo: {yaml}"
);
}
#[test]
fn prompt_uses_default_on_empty() {
let mut input: &[u8] = b"\n";
let v = prompt(&mut input, "Q", Some("def")).expect("ok");
assert_eq!(v, "def");
let mut input: &[u8] = b"\n";
let v = prompt_optional(&mut input, "Q").expect("ok");
assert!(v.is_none());
}
}