mod cli; mod knowledge; mod llm; mod model; mod planner; mod prompt; mod templates; use anyhow::Result; use clap::Parser; use cli::{Cli, Commands}; use colored::Colorize; use dialoguer::{theme::ColorfulTheme, Input, Select}; use llm::{Backend, LlmConfig}; use model::ProjectPlan; use std::collections::BTreeMap; use std::fs; use std::path::Path; fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { None => { println!("{}", "Planner — interactive mode".bold()); run_wizard(None, ".".to_string(), cli.llm_ip, cli.llm_port)?; } Some(Commands::New { name, out, llm_ip, llm_port }) => { run_wizard(name, out, llm_ip, llm_port)?; } Some(Commands::Templates) => { for t in templates::list_templates() { println!("{:<14} {} [{}]", t.manifest.slug, t.manifest.description, t.manifest.category); } } Some(Commands::Show { slug }) => { if let Some(t) = templates::find_template(&slug) { println!("{} — {}", t.manifest.name, t.manifest.description); for (p, _) in &t.files { println!(" {}", p); } } else { eprintln!("template not found: {}", slug); } } Some(Commands::Init { template, name, out }) => { do_init(&template, name, &out)?; } Some(Commands::Plan { idea, r#type, language, out, llm_ip, llm_port, constraints }) => { let (db, auth, deploy, scale, mut features) = planner::infer_requirements(&idea); let ptype = r#type.clone().unwrap_or_else(|| "cli".to_string()); let template_slug = planner::template_slug_for(&knowledge::project_type_aliases(&ptype)).map(|s| s.to_string()); let cfg_opt = llm_ip.as_ref().map(|ip| LlmConfig::new(llm::Backend::Llamacpp, None, None, Some(format!("http://{}:{}", ip, llm_port)))); if let Some(cfg) = &cfg_opt { // suggest features if empty or refine if features.is_empty() { let prompt = format!("Suggest 8-12 concrete features for a {} project: {}. Return ONLY a JSON array of strings.", ptype, idea); if let Ok(out) = llm::generate(cfg, &prompt) { // try JSON let mut parsed = Vec::new(); if let Some(s) = out.find('[') { if let Some(e) = out.rfind(']') { if e > s { if let Ok(arr) = serde_json::from_str::>(&out[s..=e]) { parsed = arr.into_iter().map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(); } } } } if parsed.is_empty() { parsed = llm::parse_feature_lines(&out); } if !parsed.is_empty() { features = parsed; } } } else { if let Ok(refined) = llm::refine_features(cfg, &features, &ptype, &idea) { features = refined; } } } let input = planner::PlanInput { name: slugify(&idea.chars().take(30).collect::()), description: idea.clone(), project_type_raw: ptype, audience: "developers".to_string(), scale, language_pref: language, database: db, auth, deployment: deploy, features, constraints: constraints.map(|s| s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()).unwrap_or_default(), template_slug, }; let mut plan = planner::build_plan(input.clone()); if let Some(cfg) = &cfg_opt { println!("{}", "Consulting local model...".dimmed()); match llm::plan_assistance(cfg, &input, &plan) { Ok(notes) => { if notes.contains("## ") { // merge risks from LLM into plan.risks if let Some(start) = notes.find("## Risks to Watch") { let header = "## Risks to Watch"; let rest = ¬es[start + header.len()..]; let end = rest.find("## ").unwrap_or(rest.len()); let section = &rest[..end]; for line in section.lines() { let l = line.trim().trim_start_matches(['-', '*', ' ']).trim().to_string(); if !l.is_empty() && !l.starts_with('#') && !plan.risks.contains(&l) { plan.risks.push(l); } } } plan.ai_assistance = Some(notes); } else { eprintln!("local model returned no structured sections; skipping."); } } Err(e) => eprintln!("local model failed: {}", e), } } write_plan(&plan, Path::new(&out))?; } } Ok(()) } fn run_wizard(name_opt: Option, out: String, llm_ip: Option, llm_port: u16) -> Result<()> { let theme = ColorfulTheme::default(); let mut backend = Backend::Auto; let mut model: Option = None; let ollama_url: Option = None; let mut llama_url: Option = None; let use_local = if let Some(ip) = &llm_ip { backend = Backend::Llamacpp; llama_url = Some(format!("http://{}:{}", ip, llm_port)); println!("{}", format!("Using LAN LLM at http://{}:{}", ip, llm_port).green()); true } else { let use_local = Select::with_theme(&theme) .with_prompt("Use a local model to assist with planning?") .items(&["no", "yes"]) .default(0) .interact()? == 1; if use_local { let backends = ["auto", "ollama", "llamacpp"]; let bi = Select::with_theme(&theme) .with_prompt("Backend") .items(&backends) .default(0) .interact()?; backend = Backend::parse(backends[bi]); let m: String = Input::with_theme(&theme) .with_prompt("Model name (blank = default)") .default(String::new()) .interact_text()?; if !m.trim().is_empty() { model = Some(m); } } use_local }; let name: String = match name_opt { Some(n) => n, None => Input::with_theme(&theme) .with_prompt("Project name") .default("my-project".to_string()) .interact_text()?, }; let description: String = Input::with_theme(&theme) .with_prompt("One-line description / idea") .interact_text()?; let types = ["cli", "rest-api", "web-app", "library", "desktop", "mobile", "data-pipeline", "ml-project", "game", "other"]; let ti = Select::with_theme(&theme) .with_prompt("Project type") .items(&types) .default(0) .interact()?; let project_type_raw = types[ti].to_string(); let audience: String = Input::with_theme(&theme) .with_prompt("Target audience") .default("developers".to_string()) .interact_text()?; let scales = ["prototype", "small", "medium", "large"]; let si = Select::with_theme(&theme).with_prompt("Scale").items(&scales).default(1).interact()?; let scale = scales[si].to_string(); let langs = ["auto", "rust", "go", "python", "typescript", "java", "kotlin", "csharp", "swift"]; let li = Select::with_theme(&theme).with_prompt("Language preference").items(&langs).default(0).interact()?; let language_pref = if langs[li] == "auto" { None } else { Some(langs[li].to_string()) }; let dbs = ["none", "sqlite", "postgres", "mongo"]; let di = Select::with_theme(&theme).with_prompt("Database").items(&dbs).default(0).interact()?; let database = dbs[di].to_string(); let auths = ["none", "basic", "oauth", "api-key"]; let ai = Select::with_theme(&theme).with_prompt("Auth").items(&auths).default(0).interact()?; let auth = auths[ai].to_string(); let deploys = ["local", "cloud", "container", "serverless", "desktop"]; let dpi = Select::with_theme(&theme).with_prompt("Deployment target").items(&deploys).default(0).interact()?; let deployment = deploys[dpi].to_string(); // Feature planning println!("{}", "Plan features (comma-separated). Examples will vary by project type.".dimmed()); let features_str: String = Input::with_theme(&theme) .with_prompt("Features") .default(String::new()) .interact_text()?; let mut features: Vec = features_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); if use_local && features.is_empty() { let cfg = LlmConfig::new(backend, model.clone(), ollama_url.clone(), llama_url.clone()); let prompt = format!("Suggest 8-12 concrete features for a {} project: {}. Return one feature per line, no extra text.", project_type_raw, description); if let Ok(out) = llm::generate(&cfg, &prompt) { for line in out.lines() { let l = line.trim().trim_start_matches(['-', '*', '•', ' ']).trim().to_string(); if !l.is_empty() { features.push(l); } } if !features.is_empty() { println!("{}", "Suggested features:".green()); for f in &features { println!(" - {}", f); } } } } if use_local && !features.is_empty() { let cfg = LlmConfig::new(backend, model.clone(), ollama_url.clone(), llama_url.clone()); println!("{}", "Refining features...".dimmed()); if let Ok(refined) = llm::refine_features(&cfg, &features, &project_type_raw, &description) { println!("{}", "Refined features:".green()); for f in &refined { println!(" - {}", f); } features = refined; } } let constraints_str: String = Input::with_theme(&theme) .with_prompt("Constraints (comma-separated, optional)") .default(String::new()) .interact_text()?; let constraints: Vec = constraints_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); let templates_list = templates::list_templates(); let mut tmpl_choices: Vec = vec!["(none)".to_string()]; for t in &templates_list { tmpl_choices.push(format!("{} — {}", t.manifest.slug, t.manifest.description)); } let tsi = Select::with_theme(&theme) .with_prompt("Template to reference in plan (optional)") .items(&tmpl_choices) .default(0) .interact()?; let template_slug = if tsi == 0 { None } else { Some(templates_list[tsi - 1].manifest.slug.clone()) }; let input = planner::PlanInput { name: name.clone(), description, project_type_raw, audience, scale, language_pref, database, auth, deployment, features: features.clone(), constraints, template_slug: template_slug.clone(), }; let input_for_llm = input.clone(); let mut plan = planner::build_plan(input); if use_local { let cfg = LlmConfig::new(backend, model, ollama_url, llama_url); println!("{}", "Consulting local model...".dimmed()); // feature suggestions let feat_prompt = format!( "Suggest 8-12 concrete features for a {} project described as: {}. Return a bullet list only.", plan.project_type, plan.description ); if let Ok(sugg) = llm::generate(&cfg, &feat_prompt) { // append suggested features as a note in ai_assistance later; store temporarily plan.ai_assistance = Some(format!("## Suggested Features\n{}\n", sugg)); } match llm::plan_assistance(&cfg, &input_for_llm, &plan) { Ok(notes) => { if notes.contains("## ") { if let Some(start) = notes.find("## Risks to Watch") { let header = "## Risks to Watch"; let rest = ¬es[start + header.len()..]; let end = rest.find("## ").unwrap_or(rest.len()); let section = &rest[..end]; for line in section.lines() { let l = line.trim().trim_start_matches(['-', '*', ' ']).trim().to_string(); if !l.is_empty() && !l.starts_with('#') && !plan.risks.contains(&l) { plan.risks.push(l); } } } let combined = match &plan.ai_assistance { Some(prev) => format!("{}\n\n{}", prev, notes), None => notes, }; plan.ai_assistance = Some(combined); } else { eprintln!("local model returned no structured sections; skipping."); } } Err(e) => eprintln!("local model failed: {}", e), } } let out_path = Path::new(&out); fs::create_dir_all(out_path)?; write_plan(&plan, out_path)?; println!("{}", "Done. PLAN.md written — feed it to your LLM.".green().bold()); Ok(()) } fn do_init(template: &str, name: Option, out: &str) -> Result<()> { let t = templates::find_template(template).ok_or_else(|| anyhow::anyhow!("template not found: {}", template))?; let name = name.unwrap_or_else(|| template.to_string()); let mut vars = BTreeMap::new(); vars.insert("name".to_string(), slugify(&name)); vars.insert("description".to_string(), format!("{} project", name)); let out_path = Path::new(out); fs::create_dir_all(out_path)?; let written = templates::scaffold_template(&t, &vars, out_path)?; for w in written { println!("{}", w); } Ok(()) } fn write_plan(plan: &ProjectPlan, out: &Path) -> Result<()> { fs::create_dir_all(out)?; let content = prompt::render_prompt(plan); fs::write(out.join("PLAN.md"), content)?; let json = serde_json::to_string_pretty(plan)?; fs::write(out.join("plan.json"), json)?; println!("{}", format!("Wrote {}/PLAN.md", out.display())); Ok(()) } fn slugify(s: &str) -> String { let mut out = String::new(); for c in s.chars() { if c.is_alphanumeric() { out.push(c.to_ascii_lowercase()); } else if c == ' ' || c == '-' || c == '_' { if !out.ends_with('-') { out.push('-'); } } } let out = out.trim_matches('-').to_string(); if out.is_empty() { "project".to_string() } else { out } }