use std::fs;
use std::io::{self, IsTerminal, 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;
const FIRST_PROMPT: &str = "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.";
#[derive(Clone, Copy, PartialEq)]
enum AiChoice {
ClaudeDesktop,
ClaudeCode,
None,
}
struct SetupConfig {
projects_dir: PathBuf,
ai: AiChoice,
}
pub struct SetupArgs {
pub dry_run: bool,
pub skip_install: bool,
pub elevated: bool,
pub lang: Option<String>,
pub ai: Option<String>,
pub projects_dir: Option<String>,
pub name: Option<String>,
}
pub fn run(args: SetupArgs) {
let dry_run = args.dry_run;
let skip_install = args.skip_install;
banner();
if let Some((lang, ai, projects_dir, name)) = elevated_answers(&args) {
let project_path = projects_dir.join(&name);
run_first_install(&lang, ai, &projects_dir, &project_path, skip_install, true);
return;
}
if !dry_run && !io::stdin().is_terminal() {
println!();
println!(
" {} Setup is interactive and needs a real terminal.",
icon_info().blue()
);
println!(
" {} Open a new terminal and run: {}",
icon_play().green(),
"tina4 setup".bold()
);
println!();
return;
}
match load_config() {
Some(cfg) => run_quick(dry_run, skip_install, cfg),
None => run_first(dry_run, skip_install),
}
}
fn run_first(dry_run: bool, skip_install: bool) {
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;
}
if !skip_install {
elevate_for_install(&lang, ai, &projects_dir, &name);
}
run_first_install(&lang, ai, &projects_dir, &project_path, skip_install, false);
}
fn run_first_install(
lang: &str,
ai: AiChoice,
projects_dir: &Path,
project_path: &Path,
skip_install: bool,
elevated: bool,
) {
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 => {}
}
save_config(projects_dir, ai);
}
scaffold_into(projects_dir, project_path, lang, ai, elevated);
pause_if_elevated(elevated);
}
fn run_quick(dry_run: bool, skip_install: bool, cfg: SetupConfig) {
println!(
" {} Using your saved setup — projects in {}, AI: {}.",
icon_info().blue(),
cfg.projects_dir.display().to_string().cyan(),
ai_label(cfg.ai)
);
println!(" {}", "(to change these, delete ~/.tina4/setup.conf and run setup again)".dimmed());
println!();
let lang = choose_language();
let name = prompt("Name of your new project", "tina4example");
let project_path = cfg.projects_dir.join(&name);
let need_runtime = !runtime_present(&lang);
if dry_run {
println!();
println!(" {} Dry run — no changes made. This would:", icon_info().blue());
if need_runtime && !skip_install {
println!(" - install the {} runtime (new language for this machine)", lang);
}
println!(" - scaffold '{}' at {}", name, project_path.display());
println!(" - write a CLAUDE.md into the project");
if cfg.ai == AiChoice::ClaudeDesktop {
println!(" - open Claude Desktop");
}
println!();
return;
}
if need_runtime && !skip_install {
elevate_for_install(&lang, cfg.ai, &cfg.projects_dir, &name);
}
println!();
println!("{} Building your project...\n", icon_play().green());
if skip_install {
println!(" {} --skip-install: skipping runtime install\n", icon_info().blue());
} else if need_runtime {
install::run(&lang);
} else {
println!(" {} {} runtime already installed", icon_ok().green(), pretty_lang(&lang));
}
scaffold_into(&cfg.projects_dir, &project_path, &lang, cfg.ai, false);
pause_if_elevated(false);
}
fn scaffold_into(projects_dir: &Path, project_path: &Path, lang: &str, ai: AiChoice, elevated: bool) {
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), project_path.file_name().and_then(|s| s.to_str()));
write_project_claude_md(project_path, lang, ai);
let name = project_path.file_name().and_then(|s| s.to_str()).unwrap_or("app");
write_project_mcp_json(project_path, lang, name);
whats_next(project_path, ai, elevated);
}
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) — the chat app; opens your project ready to build".dimmed());
println!(" 2. {} {}", "Claude Code".bold(), "— AI in your terminal, opens a real coding session in your project".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 elevate_for_install(lang: &str, ai: AiChoice, projects_dir: &Path, name: &str) {
if !console::is_windows() {
return;
}
if is_admin_windows() {
return;
}
println!();
println!(" {} Setup needs Administrator rights to install software.", icon_info().blue());
println!(" {} Approve the Windows prompt — a new window will finish the install.", icon_info().blue());
println!();
let Ok(exe) = std::env::current_exe() else { return };
let q = |s: &str| s.replace('\'', "''");
let arglist = format!(
"'setup','--elevated','--lang','{lang}','--ai','{ai}','--projects-dir','{dir}','--name','{name}'",
lang = q(lang),
ai = ai_env(ai),
dir = q(&projects_dir.display().to_string()),
name = q(name),
);
let cmd = format!(
"Start-Process -FilePath '{exe}' -ArgumentList {arglist} -Verb RunAs",
exe = q(&exe.display().to_string()),
);
let launched = Command::new("powershell")
.args(["-NoProfile", "-Command", &cmd])
.status()
.map(|s| s.success())
.unwrap_or(false);
if launched {
println!(" {} Continuing in the elevated window...", icon_ok().green());
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 elevated_answers(args: &SetupArgs) -> Option<(String, AiChoice, PathBuf, String)> {
if !args.elevated {
return None;
}
let lang = args.lang.clone()?;
let ai = match args.ai.as_deref()? {
"code" => AiChoice::ClaudeCode,
"none" => AiChoice::None,
_ => AiChoice::ClaudeDesktop,
};
let dir = PathBuf::from(args.projects_dir.clone()?);
let name = args.name.clone()?;
Some((lang, ai, dir, name))
}
fn ai_env(ai: AiChoice) -> &'static str {
match ai {
AiChoice::ClaudeDesktop => "desktop",
AiChoice::ClaudeCode => "code",
AiChoice::None => "none",
}
}
fn pause_if_elevated(elevated: bool) {
if elevated {
let _ = prompt("\n Setup finished — press Enter to close this window", "");
}
}
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 claude_desktop_installed() {
println!(" {} Claude Desktop already installed", icon_ok().green());
return;
}
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 claude_desktop_installed() -> bool {
if console::is_windows() {
claude_desktop_target().is_some()
|| std::env::var("LOCALAPPDATA")
.map(|l| Path::new(&l).join("AnthropicClaude").exists())
.unwrap_or(false)
} else if cfg!(target_os = "macos") {
Path::new("/Applications/Claude.app").exists()
} else {
false
}
}
fn claude_desktop_target() -> Option<PathBuf> {
if !console::is_windows() {
return None;
}
let env = |k: &str| std::env::var(k).ok().map(PathBuf::from);
let local = env("LOCALAPPDATA");
let appdata = env("APPDATA");
let programdata = env("PROGRAMDATA");
let mut candidates: Vec<PathBuf> = Vec::new();
if let Some(local) = &local {
let root = local.join("AnthropicClaude");
candidates.push(root.join("claude.exe"));
if let Ok(rd) = std::fs::read_dir(&root) {
let mut apps: Vec<PathBuf> = rd
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| {
p.is_dir()
&& p.file_name()
.and_then(|s| s.to_str())
.map(|n| n.starts_with("app-"))
.unwrap_or(false)
})
.collect();
apps.sort();
if let Some(newest) = apps.pop() {
candidates.push(newest.join("claude.exe"));
}
}
candidates.push(local.join("Programs").join("claude").join("Claude.exe"));
}
for base in [appdata.as_ref(), programdata.as_ref()].into_iter().flatten() {
let sm = base
.join("Microsoft")
.join("Windows")
.join("Start Menu")
.join("Programs");
candidates.push(sm.join("Claude.lnk"));
candidates.push(sm.join("Claude").join("Claude.lnk"));
candidates.push(sm.join("Anthropic").join("Claude.lnk"));
}
candidates.into_iter().find(|p| p.exists())
}
fn claude_code_installed() -> bool {
refresh_local_bin_path();
if which::which("claude").is_ok() {
return true;
}
let local_bin = home_dir().join(".local").join("bin");
let candidates: &[&str] = if console::is_windows() {
&["claude.exe", "claude.cmd", "claude.ps1", "claude"]
} else {
&["claude"]
};
candidates.iter().any(|c| local_bin.join(c).exists())
}
fn ensure_claude_code() {
if claude_code_installed() {
println!(" {} Claude Code already installed", icon_ok().green());
return;
}
println!(" {} Installing Claude Code (no Node required)...", icon_play().green());
if console::is_windows() {
let _ = Command::new("powershell")
.args(["-NoProfile", "-Command", "irm https://claude.ai/install.ps1 | iex"])
.status();
} else {
let _ = Command::new("sh")
.args(["-c", "curl -fsSL https://claude.ai/install.sh | bash"])
.status();
}
refresh_local_bin_path();
if which::which("claude").is_ok() {
println!(" {} Claude Code installed", icon_ok().green());
} else {
println!(
" {} Claude Code installed — open a new terminal to use `claude` (docs: {})",
icon_info().blue(),
"https://docs.claude.com/claude-code".cyan()
);
}
}
fn refresh_local_bin_path() {
let bin = home_dir().join(".local").join("bin");
if !bin.exists() {
return;
}
let sep = if console::is_windows() { ';' } else { ':' };
let current = std::env::var("PATH").unwrap_or_default();
let bin_s = bin.display().to_string();
if !current.split(sep).any(|p| p == bin_s) {
std::env::set_var("PATH", format!("{bin_s}{sep}{current}"));
}
}
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_mcp_json(project_path: &Path, lang: &str, name: &str) {
let file = project_path.join(".mcp.json");
if file.exists() {
println!(" {} .mcp.json already present — left as-is", icon_info().blue());
return;
}
let port = match lang {
"php" => 7145,
"ruby" => 7147,
"nodejs" => 7148,
_ => 7146, };
let url = "http://localhost:".to_string() + &port.to_string() + "/__dev/mcp/sse";
let mut content = String::new();
content.push_str("{\n");
content.push_str(" \"mcpServers\": {\n");
content.push_str(" \"");
content.push_str(name);
content.push_str("\": {\n");
content.push_str(" \"type\": \"sse\",\n");
content.push_str(" \"url\": \"");
content.push_str(&url);
content.push_str("\"\n");
content.push_str(" }\n");
content.push_str(" }\n");
content.push_str("}\n");
match fs::write(&file, content) {
Ok(_) => println!(
" {} Wrote .mcp.json (Claude Code → live tina4 tools at /__dev/mcp)",
icon_ok().green()
),
Err(e) => eprintln!(" {} Could not write .mcp.json: {}", icon_warn().yellow(), e),
}
}
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 name = project_path.file_name().and_then(|s| s.to_str()).unwrap_or("app");
let ai_line = match ai {
AiChoice::ClaudeCode => "You're working in **Claude Code** — you have this project's CLAUDE.md and (via .mcp.json) its live `/__dev/mcp` tools.",
AiChoice::ClaudeDesktop => "You're working in **Claude Desktop**.",
AiChoice::None => "This project is set up for AI-assisted development.",
};
let mut s = String::new();
s.push_str(&format!("# {} — Tina4 {} project\n\n", name, pretty_lang(lang)));
s.push_str(ai_line);
s.push('\n');
s.push_str(r#"
**Tina4 v3** — *The Intelligent Native Application 4ramework*. Built for AI:
zero third-party dependencies, convention over configuration, one small
consistent API. Use the framework's built-ins (routing, ORM, migrations, Frond
templates, auth/JWT, queues, cache, sessions, WebSockets, GraphQL) before any
library or hand-rolled code.
## Source of truth — check before you guess
1. **Skills** in `~/.claude/skills/` — **tina4-developer** (+ **tina4-js** for
the reactive frontend). These document the real API surface.
2. **https://tina4.com** — docs + the **Ask Tina4** RAG box (ask it any
framework question; it answers from the live corpus).
3. **`/__dev/mcp`** live tools (wired via the `.mcp.json` in this folder) —
query for real routes, models, and signatures when the dev server is running.
## Environment (.env)
```bash
TINA4_DEBUG=true # dev mode: hot-reload, error overlay, /__dev
TINA4_SECRET=change-me # JWT signing secret
TINA4_DATABASE_URL=sqlite:///data/app.db # driver://host:port/db
TINA4_DATABASE_USERNAME= # db user (blank for sqlite)
TINA4_DATABASE_PASSWORD= # db password
TINA4_LOG_LEVEL=INFO # ALL | DEBUG | INFO | WARNING | ERROR
TINA4_API_KEY= # optional static bearer token
TINA4_CACHE_BACKEND=memory # memory | file | redis | valkey | memcached | mongodb | database
TINA4_SESSION_BACKEND=file # session store
TINA4_NO_BROWSER=false # set true to never auto-open the browser
```
"#);
s.push_str("## Database & drivers\n\n");
s.push_str("SQLite works out of the box:\n\n");
s.push_str("```bash\n");
match lang {
"php" => s.push_str("TINA4_DATABASE_URL=sqlite:///data/app.db\n"),
"ruby" => s.push_str("TINA4_DATABASE_URL=sqlite:///data/app.db\n"),
"nodejs" => s.push_str("TINA4_DATABASE_URL=sqlite://./data/app.db # SQLite is built in via node:sqlite — no install\n"),
_ => s.push_str("TINA4_DATABASE_URL=sqlite:///data/app.db # three slashes = relative to cwd\n"),
}
s.push_str("```\n\n");
s.push_str("Connection URLs are `driver://host:port/database` (sqlite / postgres / postgresql / mysql / mssql / firebird).\n\n");
s.push_str("Add a Postgres or MySQL driver:\n\n");
match lang {
"php" => {
s.push_str("- **Postgres**: enable the PDO PostgreSQL extension (no Composer pkg — Tina4 v3 has zero runtime deps). macOS `brew install php` ships it, or `pecl install pdo_pgsql`; Debian/Ubuntu `sudo apt-get install php-pgsql`. Verify: `php -m | grep pdo_pgsql`.\n");
s.push_str("- **MySQL**: enable the PDO MySQL extension (`sudo apt-get install php-mysql`, or bundled with Homebrew PHP / `pecl install pdo_mysql`). Verify: `php -m | grep pdo_mysql`.\n\n");
s.push_str("```bash\nTINA4_DATABASE_URL=postgres://user:pass@localhost:5432/mydb\n```\n\n");
}
"ruby" => {
s.push_str("```bash\nbundle add pg # Postgres\nbundle add mysql2 # MySQL\n```\n\n");
s.push_str("```bash\nTINA4_DATABASE_URL=postgres://localhost:5432/mydb # + TINA4_DATABASE_USERNAME / TINA4_DATABASE_PASSWORD\n```\n\n");
}
"nodejs" => {
s.push_str("```bash\nnpm i pg # Postgres\nnpm i mysql2 # MySQL\n```\n\n");
s.push_str("```bash\nTINA4_DATABASE_URL=postgres://localhost:5432/mydb\n```\n\n");
}
_ => {
s.push_str("```bash\nuv add psycopg2-binary # Postgres\nuv add mysql-connector-python # MySQL\n```\n\n");
s.push_str("```bash\nTINA4_DATABASE_URL=postgresql://localhost:5432/mydb # + TINA4_DATABASE_USERNAME / TINA4_DATABASE_PASSWORD\n```\n\n");
}
}
s.push_str("## Add a route\n\n");
match lang {
"php" => {
s.push_str("`src/routes/hello.php` (auto-discovered; one resource/verb per file):\n\n");
s.push_str("```php\n");
s.push_str(r#"<?php
\Tina4\Router::get("/hello", function ($request, $response) {
return $response->json(["message" => "Hello from Tina4"]);
});
"#);
s.push_str("```\n\n");
}
"ruby" => {
s.push_str("`src/routes/hello.rb` (auto-discovered):\n\n");
s.push_str("```ruby\n");
s.push_str(r#"require "tina4"
Tina4.get "/hello" do |request, response|
response.json({ message: "Hello from Tina4" }, Tina4::HTTP_OK)
end
"#);
s.push_str("```\n\n");
}
"nodejs" => {
s.push_str("`src/routes/hello/get.ts` — **file-based**: the directory is the URL path, the FILENAME is the HTTP method (`get.ts` = `GET /hello`):\n\n");
s.push_str("```ts\n");
s.push_str(r#"import type { Tina4Request, Tina4Response } from "@tina4/core";
export default async function (req: Tina4Request, res: Tina4Response) {
return res.json({ message: "Hello from Tina4" });
}
"#);
s.push_str("```\n\n");
}
_ => {
s.push_str("`src/routes/hello.py` (auto-discovered; one resource per file):\n\n");
s.push_str("```python\n");
s.push_str(r#"from tina4_python.core.router import get
@get("/hello")
async def hello(request, response):
return response({"message": "Hello from Tina4"})
"#);
s.push_str("```\n\n");
}
}
s.push_str("## Templates (Frond)\n\n");
s.push_str("Frond is the built-in zero-dep Twig-compatible engine; templates live in `src/templates/` (`.twig`). Common syntax:\n\n");
s.push_str("```twig\n");
s.push_str(r#"{% extends "base.twig" %}
{% block content %}
<h1>{{ title }}</h1>
<ul>
{% for x in items %}
<li>{{ x.name | upper }}</li>
{% endfor %}
</ul>
{% endblock %}
"#);
s.push_str("```\n\n");
s.push_str("Render it from a route:\n\n");
match lang {
"php" => s.push_str("```php\nreturn $response->render(\"dashboard.twig\", [\"title\" => \"Dashboard\"]);\n```\n\n"),
"ruby" => s.push_str("```ruby\nhtml = Tina4::Template.render(\"index.twig\", { title: \"Home\" })\nresponse.html(html)\n```\n\n"),
"nodejs" => s.push_str("```ts\nreturn res.render(\"page.twig\", { title: \"Home\" });\n```\n\n"),
_ => s.push_str("```python\nreturn response.render(\"hello.twig\", {\"name\": \"Tina4\"})\n```\n\n"),
}
s.push_str(r#"## How to run
```bash
tina4 serve
```
Dev server: watches files, hot-reloads, opens the app + the `/__dev` dashboard.
> Dev runs two ports: the **base** port hot-reloads (for you); **base+1000** is
> stable and 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/` | Frond (`.twig`) |
| Frontend behaviour | `tina4-js` | tina4-js signals + html templates |
## Golden rules
- **Use built-ins first** — Tina4 is zero-dep; reach for the framework before any library or hand-rolled code.
- **Don't guess API names** — check the skills / **Ask Tina4** at https://tina4.com / the live `/__dev/mcp` tools.
- Routes return data; **`response()`** (called, not `response.json`) auto-serializes models, lists, and `DatabaseResult` to JSON.
- **One resource per file** in `src/routes/` and `src/orm/`.
- All schema changes go through migrations (`tina4 generate migration` → `tina4 migrate`) — never raw DDL in routes.
- No inline styles / no hardcoded hex — use tina4-css classes + SCSS in `src/scss/`.
- Env comes from `.env`; `TINA4_DEBUG=true` in dev.
- All links point to **https://tina4.com**.
"#);
match lang {
"php" => s.push_str("- **PHP gotcha:** `return $response(...)` (callable) and `$response->json(...)` both emit JSON and auto-serialize models/arrays/`DatabaseResult`. Route files of pure `Router::*()` calls hot-reload; files declaring top-level functions/classes need a server restart.\n"),
"ruby" => s.push_str("- **Ruby gotcha:** the handler block is `|request, response|`; pass an HTTP status like `Tina4::HTTP_OK` to `response.json`. The `sqlite3` gem ships by default; `pg`/`mysql2` are add-ons.\n"),
"nodejs" => s.push_str("- **Node.js gotcha:** the route filename = HTTP method (`get.ts`/`post.ts`/…); dirs map to the path (`[id]` → `{id}`). Use `.js` extensions in import paths. `res.json(model | model[] | DatabaseResult)` auto-serializes.\n"),
_ => s.push_str("- **Python gotcha:** route decorators (`@get`/`@post`/…) must be INNERMOST (closest to `def`); `@noauth`/`@secured`/`@description` go above. GET is public; POST/PUT/PATCH/DELETE need auth unless `@noauth()`. Use `response(...)`, not `response.json()`.\n"),
}
s.push_str("\n## A good first prompt\n\n");
s.push_str("> ");
s.push_str(FIRST_PROMPT);
s.push('\n');
match fs::write(&file, s) {
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;
}
if cfg!(target_os = "macos") {
let _ = Command::new("open").args(["-a", "Claude"]).status();
} else if console::is_windows() {
match claude_desktop_target() {
Some(target) => {
let _ = Command::new("cmd")
.args(["/C", "start", ""])
.arg(target)
.status();
}
None => {
println!(
" {} Couldn't find Claude Desktop to open — launch it from the Start menu.",
icon_info().blue()
);
}
}
}
}
fn whats_next(project_path: &Path, ai: AiChoice, elevated: bool) {
let p = project_path.display();
println!();
println!(" {} Your project is ready: {}", icon_ok().green(), p.to_string().cyan());
println!();
println!(" Start it any time:");
println!(" cd {}", p);
println!(" tina4 serve {}", "# opens your app in the browser".dimmed());
println!();
if elevated {
return;
}
match ai {
AiChoice::ClaudeCode => {
println!(
" {} Opening Claude Code in your project (it has your CLAUDE.md + live tools)...",
icon_play().green()
);
refresh_local_bin_path();
match which::which("claude") {
Ok(claude) => {
let status = if console::is_windows() {
Command::new("cmd")
.arg("/C")
.arg(&claude)
.arg(FIRST_PROMPT)
.current_dir(project_path)
.status()
} else {
Command::new(&claude)
.arg(FIRST_PROMPT)
.current_dir(project_path)
.status()
};
if status.is_err() {
println!(" {} Couldn't launch Claude Code automatically.", icon_info().blue());
println!(" Start a session: {} && {}", format!("cd {}", p).cyan(), "claude".cyan());
println!(" First prompt: {}", FIRST_PROMPT);
}
}
Err(_) => {
println!(" Start a session: {} && {}", format!("cd {}", p).cyan(), "claude".cyan());
println!(" First prompt: {}", FIRST_PROMPT);
}
}
}
AiChoice::ClaudeDesktop | AiChoice::None => {
let ans = prompt("Start it now and open it in your browser?", "y");
open_ide(ai);
if matches!(ans.trim().to_lowercase().as_str(), "" | "y" | "yes") {
let label = project_path.file_name().and_then(|s| s.to_str()).unwrap_or("your app");
println!();
println!(
" {} Starting {} — your browser will open. Press Ctrl+C to stop.",
icon_play().green(),
label.cyan()
);
println!();
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("tina4"));
let _ = Command::new(exe).arg("serve").current_dir(project_path).status();
} else {
println!(" {} No problem — run {} when you're ready.", icon_info().blue(), "tina4 serve".cyan());
}
}
}
}
fn config_path() -> PathBuf {
home_dir().join(".tina4").join("setup.conf")
}
pub fn configured_projects_dir() -> Option<PathBuf> {
let text = fs::read_to_string(config_path()).ok()?;
for line in text.lines() {
if let Some(v) = line.strip_prefix("projects_dir=") {
return Some(PathBuf::from(v.trim()));
}
}
None
}
fn load_config() -> Option<SetupConfig> {
let text = fs::read_to_string(config_path()).ok()?;
let mut projects_dir: Option<PathBuf> = None;
let mut ai = AiChoice::None;
for line in text.lines() {
if let Some(v) = line.strip_prefix("projects_dir=") {
projects_dir = Some(PathBuf::from(v.trim()));
} else if let Some(v) = line.strip_prefix("ai=") {
ai = ai_from_str(v.trim());
}
}
Some(SetupConfig { projects_dir: projects_dir?, ai })
}
fn save_config(projects_dir: &Path, ai: AiChoice) {
let path = config_path();
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let body = format!("projects_dir={}\nai={}\n", projects_dir.display(), ai_to_str(ai));
let _ = fs::write(path, body);
}
fn ai_to_str(ai: AiChoice) -> &'static str {
match ai {
AiChoice::ClaudeDesktop => "claude-desktop",
AiChoice::ClaudeCode => "claude-code",
AiChoice::None => "none",
}
}
fn ai_from_str(s: &str) -> AiChoice {
match s {
"claude-desktop" => AiChoice::ClaudeDesktop,
"claude-code" => AiChoice::ClaudeCode,
_ => AiChoice::None,
}
}
fn ai_label(ai: AiChoice) -> &'static str {
match ai {
AiChoice::ClaudeDesktop => "Claude Desktop",
AiChoice::ClaudeCode => "Claude Code",
AiChoice::None => "code editor only",
}
}
fn runtime_present(lang: &str) -> bool {
match lang {
"python" => which::which("python3").is_ok() || which::which("python").is_ok(),
"nodejs" => which::which("node").is_ok(),
"php" => which::which("php").is_ok(),
"ruby" => which::which("ruby").is_ok(),
_ => false,
}
}
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)
}