use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::console::{self, icon_info, icon_ok, icon_play, icon_warn};
use crate::{init, install};
use colored::Colorize;
#[derive(Clone, Copy, PartialEq)]
enum AiChoice {
ClaudeDesktop,
ClaudeCode,
None,
}
pub fn run(dry_run: bool, skip_install: bool) {
banner();
if !dry_run && !skip_install {
ensure_admin_windows();
}
let lang = choose_language();
let ai = choose_ai();
let projects_dir = choose_projects_dir();
let name = prompt("Name of your first project", "tina4example");
let project_path = projects_dir.join(&name);
if dry_run {
print_plan(&lang, ai, &projects_dir, &name, &project_path, skip_install);
return;
}
println!();
println!("{} Setting up — this can take a few minutes...\n", icon_play().green());
if skip_install {
println!(
" {} --skip-install: skipping runtime / git / AI / skills installs\n",
icon_info().blue()
);
} else {
install::run(&lang);
ensure_git();
install_skills_global();
match ai {
AiChoice::ClaudeDesktop => ensure_claude_desktop(),
AiChoice::ClaudeCode => ensure_claude_code(),
AiChoice::None => {}
}
}
if let Err(e) = fs::create_dir_all(&projects_dir) {
eprintln!(" {} Could not create {}: {}", icon_warn().yellow(), projects_dir.display(), e);
} else {
println!(" {} Projects folder: {}", icon_ok().green(), projects_dir.display().to_string().cyan());
}
if let Err(e) = std::env::set_current_dir(&projects_dir) {
eprintln!(" {} Could not enter {}: {}", icon_warn().yellow(), projects_dir.display(), e);
}
std::env::set_var("TINA4_INIT_NO_SERVE", "1");
init::run(Some(&lang), Some(&name));
write_project_claude_md(&project_path, &lang, ai);
open_ide(ai);
whats_next(&project_path, ai);
}
fn banner() {
println!();
println!(" {}", "Tina4 Setup".cyan());
println!(" {}", "A few questions and you'll be building.".dimmed());
println!();
}
fn choose_language() -> String {
let opts = [
("python", "Python", "recommended · APIs, AI, data"),
("nodejs", "Node.js", "real-time apps, JS/TS teams"),
("php", "PHP", "classic web, shared hosting"),
("ruby", "Ruby", "rapid prototyping"),
];
println!(" Which language do you want to build with?");
for (i, (_, name, desc)) in opts.iter().enumerate() {
let tag = if i == 0 { " (default)".green().to_string() } else { String::new() };
println!(" {}. {}{} {}", i + 1, name, tag, format!("— {}", desc).dimmed());
}
let choice = prompt("Choose 1-4", "1");
let idx = choice
.trim()
.parse::<usize>()
.unwrap_or(1)
.saturating_sub(1)
.min(opts.len() - 1);
opts[idx].0.to_string()
}
fn choose_ai() -> AiChoice {
println!();
println!(" Which AI tool do you want to build with?");
println!(" 1. {} {}", "Claude Desktop".bold(), "(default) — chat app, best for non-coders".dimmed());
println!(" 2. {} {}", "Claude Code".bold(), "— AI in your terminal".dimmed());
println!(" 3. {} {}", "Just my code editor".bold(), "— no AI".dimmed());
let choice = prompt("Choose 1-3", "1");
match choice.trim() {
"2" => AiChoice::ClaudeCode,
"3" => AiChoice::None,
_ => AiChoice::ClaudeDesktop,
}
}
fn choose_projects_dir() -> PathBuf {
let default = home_dir().join("projects");
println!();
println!(" Where should your projects live? (created if it doesn't exist)");
let entered = prompt("Projects folder", &default.display().to_string());
expand_tilde(&entered)
}
fn print_plan(
lang: &str,
ai: AiChoice,
projects_dir: &Path,
name: &str,
project_path: &Path,
skip_install: bool,
) {
println!();
println!(" {} Dry run — no changes made. This setup would:", icon_info().blue());
if skip_install {
println!(" - {} skip all system installs", "(--skip-install)".dimmed());
} else {
if console::is_windows() {
println!(" - install Chocolatey if missing (relaunching as Administrator)");
} else {
println!(" - install Homebrew if missing");
}
println!(" - install the {} runtime + tools through it", lang);
println!(" - install Git if missing");
println!(" - install the tina4 AI skills globally (~/.claude/skills)");
match ai {
AiChoice::ClaudeDesktop => println!(" - install Claude Desktop"),
AiChoice::ClaudeCode => println!(" - install Claude Code"),
AiChoice::None => {}
}
}
println!(" - create your projects folder: {}", projects_dir.display());
println!(" - scaffold '{}' at {}", name, project_path.display());
println!(" - write a CLAUDE.md into the project with instructions");
match ai {
AiChoice::ClaudeDesktop => println!(" - open Claude Desktop"),
AiChoice::ClaudeCode => println!(" - show how to start Claude Code in the project"),
AiChoice::None => {}
}
println!();
}
fn ensure_admin_windows() {
if !console::is_windows() || std::env::var("TINA4_SETUP_ELEVATED").is_ok() {
return;
}
if is_admin_windows() {
return;
}
println!(" {} Setup needs Administrator rights to install software.", icon_info().blue());
println!(" {} Approve the Windows prompt — setup continues in a new window.", icon_info().blue());
println!();
let Ok(exe) = std::env::current_exe() else { return };
let exe_str = exe.display().to_string().replace('\'', "''");
let cmd = format!(
"$env:TINA4_SETUP_ELEVATED='1'; \
Start-Process -FilePath '{exe_str}' -ArgumentList 'setup' -Verb RunAs"
);
let launched = Command::new("powershell")
.args(["-NoProfile", "-Command", &cmd])
.status()
.map(|s| s.success())
.unwrap_or(false);
if launched {
std::process::exit(0);
}
println!(
" {} Couldn't elevate automatically. Right-click your terminal, choose \
'Run as administrator', then run: tina4 setup",
icon_warn().yellow()
);
std::process::exit(1);
}
fn is_admin_windows() -> bool {
Command::new("net")
.args(["session"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn ensure_git() {
if which::which("git").is_ok() {
println!(" {} Git already installed", icon_ok().green());
return;
}
println!(" {} Installing Git...", icon_play().green());
let ok = if console::is_windows() {
run_status("choco", &["install", "git", "-y"])
} else if which::which("brew").is_ok() {
run_status("brew", &["install", "git"])
} else {
run_status("sh", &["-c", "sudo apt-get install -y git || sudo dnf install -y git"])
};
if !ok {
println!(" {} Git install skipped — install it later if you want version control", icon_warn().yellow());
}
}
fn ensure_claude_desktop() {
if console::is_windows() {
println!(" {} Installing Claude Desktop...", icon_play().green());
run_status("choco", &["install", "claude", "-y"]);
} else if which::which("brew").is_ok() {
println!(" {} Installing Claude Desktop...", icon_play().green());
run_status("brew", &["install", "--cask", "claude"]);
} else {
println!(" {} Download Claude Desktop: https://claude.ai/download", icon_info().blue());
}
}
fn ensure_claude_code() {
if which::which("claude").is_ok() {
println!(" {} Claude Code already installed", icon_ok().green());
return;
}
println!(" {} Installing Claude Code...", icon_play().green());
let installed = which::which("npm").is_ok()
&& run_status("npm", &["install", "-g", "@anthropic-ai/claude-code"]);
if installed || which::which("claude").is_ok() {
println!(" {} Claude Code installed", icon_ok().green());
} else {
println!(
" {} Install Claude Code from {} (needs Node.js, or use the native installer there)",
icon_info().blue(),
"https://docs.claude.com/claude-code".cyan()
);
}
}
fn install_skills_global() {
println!(" {} Installing tina4 AI skills (global)...", icon_play().green());
let ok = if console::is_windows() {
run_status(
"powershell",
&[
"-NoProfile",
"-Command",
"irm https://raw.githubusercontent.com/tina4stack/tina4/main/install-skills.ps1 | iex",
],
)
} else {
run_status(
"sh",
&["-c", "curl -fsSL https://raw.githubusercontent.com/tina4stack/tina4/main/install-skills.sh | sh"],
)
};
if !ok {
println!(
" {} Skills install skipped — run later: {}",
icon_warn().yellow(),
"tina4 ai".cyan()
);
}
}
fn write_project_claude_md(project_path: &Path, lang: &str, ai: AiChoice) {
let file = project_path.join("CLAUDE.md");
if file.exists() {
println!(" {} CLAUDE.md already present — left as-is", icon_info().blue());
return;
}
let ai_line = match ai {
AiChoice::ClaudeDesktop => "You are working through **Claude Desktop**.",
AiChoice::ClaudeCode => "You are working through **Claude Code** (terminal).",
AiChoice::None => "This project is set up for AI-assisted development.",
};
let content = format!(
r#"# {name} — Tina4 {lang} project
{ai_line}
This is a **Tina4** project: a self-contained backend that also serves a
[tina4-js](https://github.com/tina4stack/tina4-js) reactive frontend. Zero
external dependencies, batteries included.
## How to run
```bash
tina4 serve
```
This starts the dev server, watches your files, and hot-reloads the browser on
every change. The URL is printed when it starts.
> Dev runs two ports: the **base** port hot-reloads (for you), and **base+1000**
> is a stable port that does NOT reload — use that one when an AI is driving the
> browser so a reload doesn't interrupt it.
## Where things go
| You want to… | Put it in… | Make it with |
|-------------------------|-------------------------------------|--------------------------------------|
| Add a page or API route | `src/routes/` | `tina4 generate route <name>` |
| Add a database model | `src/orm/` | `tina4 generate model <Name>` |
| Change the schema | `migrations/` | `tina4 generate migration <name>` then `tina4 migrate` |
| Add a page template | `src/templates/` | (Twig-style templates) |
| Frontend behaviour | served at `/js/tina4js.min.js` | tina4-js signals + html templates |
## Skills
The **tina4-developer** and **tina4-js** skills are installed globally. Use
them — they are the source of truth for tina4 patterns. Don't guess API names;
the framework is small and consistent, and the skills document it exactly.
## Golden rules
- Keep it simple. Tina4 is zero-dependency — reach for the framework before a library.
- Routes return data; `response()` auto-serializes models and lists to JSON.
- Env vars are read from `.env` (already created). `TINA4_DEBUG=true` is on for dev.
## A good first prompt
> "Add a `/products` page backed by a `Product` model (name, price, image_url),
> seed three rows, and render them as cards using a tina4-js component."
"#,
name = project_path.file_name().and_then(|s| s.to_str()).unwrap_or("app"),
lang = pretty_lang(lang),
ai_line = ai_line,
);
match fs::write(&file, content) {
Ok(_) => println!(" {} Wrote {}", icon_ok().green(), "CLAUDE.md".cyan()),
Err(e) => eprintln!(" {} Could not write CLAUDE.md: {}", icon_warn().yellow(), e),
}
}
fn open_ide(ai: AiChoice) {
if ai != AiChoice::ClaudeDesktop {
return;
}
let _ = if cfg!(target_os = "macos") {
Command::new("open").args(["-a", "Claude"]).status()
} else if console::is_windows() {
Command::new("cmd").args(["/C", "start", "", "claude"]).status()
} else {
return;
};
}
fn whats_next(project_path: &Path, ai: AiChoice) {
let p = project_path.display();
println!();
println!(" {} Your project is ready: {}", icon_ok().green(), p.to_string().cyan());
println!();
println!(" Start it:");
println!(" cd {}", p);
println!(" tina4 serve {}", "# opens your app in the browser".dimmed());
println!();
match ai {
AiChoice::ClaudeDesktop => {
println!(
" Then open {} and paste the first-prompt idea from {}.",
"Claude Desktop".bold(),
"CLAUDE.md".cyan()
);
}
AiChoice::ClaudeCode => {
println!(" Then start AI in your project:");
println!(" cd {} && claude", p);
}
AiChoice::None => {
println!(" Open the project in your editor and read {}.", "CLAUDE.md".cyan());
}
}
println!();
}
fn pretty_lang(lang: &str) -> &str {
match lang {
"python" => "Python",
"nodejs" => "Node.js",
"php" => "PHP",
"ruby" => "Ruby",
other => other,
}
}
fn home_dir() -> PathBuf {
let var = if console::is_windows() { "USERPROFILE" } else { "HOME" };
std::env::var(var).map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("."))
}
fn expand_tilde(input: &str) -> PathBuf {
let trimmed = input.trim();
if trimmed == "~" {
return home_dir();
}
if let Some(rest) = trimmed.strip_prefix("~/").or_else(|| trimmed.strip_prefix("~\\")) {
return home_dir().join(rest);
}
PathBuf::from(trimmed)
}
fn prompt(label: &str, default: &str) -> String {
print!(" {} [{}]: ", label, default.dimmed());
let _ = io::stdout().flush();
let mut s = String::new();
if io::stdin().read_line(&mut s).is_err() {
return default.to_string();
}
let t = s.trim();
if t.is_empty() {
default.to_string()
} else {
t.to_string()
}
}
fn run_status(cmd: &str, args: &[&str]) -> bool {
Command::new(cmd)
.args(args)
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.map(|s| s.success())
.unwrap_or(false)
}