use anyhow::Result;
use crate::ui::style::Glyphs;
macro_rules! wizard_print {
() => {
if !crate::output::is_quiet() {
print!("\n");
}
};
($($arg:tt)*) => {
if !crate::output::is_quiet() {
print!($($arg)*);
print!("\n");
}
};
}
pub(crate) fn run_init_wizard(template: Option<String>, scaffold: bool) -> Result<()> {
use std::io::{self, BufRead, IsTerminal, Write};
use std::path::PathBuf;
if let Some(ref tmpl) = template {
return write_template_config(tmpl);
}
ensure_interactive_stdin(std::io::stdin().is_terminal())?;
if scaffold {
run_scaffold_interview()?;
}
wizard_print!();
wizard_print!(
"{} Welcome to Selfware! Let's set up your workspace.",
Glyphs::seedling()
);
wizard_print!();
let project_type = if std::path::Path::new("Cargo.toml").exists() {
"Rust (Cargo.toml)"
} else if std::path::Path::new("package.json").exists() {
"Node.js (package.json)"
} else if std::path::Path::new("pyproject.toml").exists()
|| std::path::Path::new("setup.py").exists()
{
"Python (pyproject.toml)"
} else if std::path::Path::new("go.mod").exists() {
"Go (go.mod)"
} else {
"Unknown"
};
wizard_print!(" Detecting project type... Found: {}", project_type);
wizard_print!();
wizard_print!("Step 1/4: API Endpoint");
wizard_print!(" Where should Selfware connect?");
wizard_print!(" [1] Local (http://127.0.0.1:1234/v1) - LM Studio, Ollama, vLLM");
wizard_print!(" [2] OpenAI-compatible API (https://api.openai.com/v1)");
wizard_print!(" [3] Custom endpoint");
print!(" > ");
io::stdout().flush()?;
let mut choice = String::new();
io::stdin().lock().read_line(&mut choice)?;
let endpoint = match choice.trim() {
"2" => "https://api.openai.com/v1".to_string(),
"3" => {
print!(" Enter endpoint URL: ");
io::stdout().flush()?;
let mut url = String::new();
io::stdin().lock().read_line(&mut url)?;
url.trim().to_string()
}
_ => "http://127.0.0.1:1234/v1".to_string(),
};
wizard_print!();
wizard_print!("Step 2/4: Model");
let default_model = if endpoint.contains("openai") {
"gpt-4"
} else {
"qwen3-coder"
};
print!(" Which model should Selfware use? [{}]: ", default_model);
io::stdout().flush()?;
let mut model = String::new();
io::stdin().lock().read_line(&mut model)?;
let model = if model.trim().is_empty() {
default_model.to_string()
} else {
model.trim().to_string()
};
wizard_print!();
wizard_print!("Step: API Key (optional)");
wizard_print!(" If your endpoint needs an API key (e.g. OpenRouter), paste it now.");
wizard_print!(" It is stored in your OS keyring — NOT written to the config file.");
wizard_print!(" Leave blank to skip (you can set SELFWARE_API_KEY later).");
print!(" > ");
io::stdout().flush()?;
let mut api_key = String::new();
io::stdin().lock().read_line(&mut api_key)?;
let api_key = api_key.trim();
if !api_key.is_empty() {
match crate::config::save_api_key_to_keyring(&endpoint, api_key) {
Ok(()) => wizard_print!(" {} API key saved to your OS keyring.", Glyphs::bloom()),
Err(e) => wizard_print!(
" {} Could not save to keyring ({}). Set SELFWARE_API_KEY=<key> in your environment instead.",
Glyphs::frost(),
e
),
}
}
wizard_print!();
wizard_print!("Step 3/4: Allowed Paths");
wizard_print!(" Which directories can Selfware access?");
wizard_print!(" [1] Current directory only (.)");
wizard_print!(" [2] Home directory (~)");
wizard_print!(" [3] Custom paths");
print!(" > ");
io::stdout().flush()?;
let mut path_choice = String::new();
io::stdin().lock().read_line(&mut path_choice)?;
let allowed_paths = match path_choice.trim() {
"2" => {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
allowed_paths_toml([dir_allow_entry(&home)])
}
"3" => {
print!(" Enter paths (comma-separated): ");
io::stdout().flush()?;
let mut paths = String::new();
io::stdin().lock().read_line(&mut paths)?;
allowed_paths_toml(
paths
.split(',')
.map(str::trim)
.filter(|p| !p.is_empty())
.map(custom_allow_entry),
)
}
_ => {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
allowed_paths_toml([dir_allow_entry(&cwd)])
}
};
wizard_print!();
wizard_print!("Step 4/4: Execution Mode");
wizard_print!(" How should Selfware handle file changes?");
wizard_print!(" [1] Normal - Ask before every edit (safest)");
wizard_print!(" [2] AutoEdit - Auto-approve file edits, confirm commands");
wizard_print!(" [3] YOLO - Auto-approve everything (use with caution!)");
print!(" > ");
io::stdout().flush()?;
let mut mode_choice = String::new();
io::stdin().lock().read_line(&mut mode_choice)?;
let mode = match mode_choice.trim() {
"2" => "autoedit",
"3" => "yolo",
_ => "normal",
};
wizard_print!();
write_config_file(&endpoint, &model, mode, &allowed_paths)
}
fn ensure_interactive_stdin(is_terminal: bool) -> Result<()> {
if !is_terminal {
anyhow::bail!(
"`selfware init` is an interactive wizard and needs a terminal on stdin. \
For a non-interactive setup use `selfware init --template <rust|python|node|minimal>`, \
or re-run in a terminal."
);
}
Ok(())
}
fn toml_quote(s: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
c if (c as u32) < 0x20 || c as u32 == 0x7f => {
let _ = write!(out, "\\u{:04X}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
fn dir_allow_entry(dir: &std::path::Path) -> String {
format!("{}/**", dir.display())
}
fn custom_allow_entry(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.contains(['*', '?', '[']) {
return trimmed.to_string();
}
let base = trimmed.trim_end_matches(['/', '\\']);
if base.len() == 2 && base.ends_with(':') {
format!("{}**", trimmed)
} else {
format!("{}/**", base)
}
}
fn allowed_paths_toml<I, S>(entries: I) -> String
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let quoted: Vec<String> = entries
.into_iter()
.map(|e| toml_quote(e.as_ref()))
.collect();
format!("[{}]", quoted.join(", "))
}
fn run_scaffold_interview() -> Result<()> {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
wizard_print!(
" {} --scaffold needs an interactive terminal; skipping.",
Glyphs::frost()
);
return Ok(());
}
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let ctx = crate::interview::run_interview("Scaffold a new project", &cwd)?;
match crate::templates::scaffold_from_context(&ctx, &cwd) {
Ok(files) => {
wizard_print!(" {} Scaffolded {} files:", Glyphs::bloom(), files.len());
for f in &files {
wizard_print!(" {}", f);
}
}
Err(e) => wizard_print!(" {} Could not scaffold: {}", Glyphs::frost(), e),
}
Ok(())
}
fn write_template_config(template: &str) -> Result<()> {
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
match template {
"rust" | "python" | "node" | "nodejs" | "typescript" => {
wizard_print!(" {} Using '{}' template...", Glyphs::gear(), template);
let lang_key = match template {
"node" | "nodejs" | "typescript" => "nodejs",
other => other,
};
let project_name = cwd
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("my-project")
.to_string();
let engine = crate::templates::TemplateEngine::new();
let opts = crate::templates::ScaffoldOptions {
description: format!("A {} project scaffolded by Selfware", template),
framework: None,
with_ci: true,
with_tests: true,
qa_profile: "standard".into(),
};
match engine.scaffold_project(lang_key, &project_name, &cwd, &opts) {
Ok(files) => {
wizard_print!(" {} Scaffolded {} files:", Glyphs::bloom(), files.len());
for f in &files {
wizard_print!(" {}", f);
}
}
Err(e) => {
wizard_print!(" {} Could not scaffold project: {}", Glyphs::frost(), e);
}
}
}
"minimal" => {
wizard_print!(" {} Using 'minimal' template...", Glyphs::gear());
}
other => {
anyhow::bail!(
"Unknown template '{}'. Available templates: rust, python, node, nodejs, typescript, minimal",
other
);
}
}
let (endpoint, model, mode, allowed_paths) = match template {
"rust" | "python" | "node" | "nodejs" | "typescript" => (
"http://127.0.0.1:1234/v1".to_string(),
"qwen3-coder".to_string(),
"normal",
allowed_paths_toml([dir_allow_entry(&cwd)]),
),
_ => (
"http://127.0.0.1:1234/v1".to_string(),
"qwen3-coder".to_string(),
"normal",
"[\"./**\"]".to_string(),
),
};
write_config_file(&endpoint, &model, mode, &allowed_paths)
}
fn build_config_content(endpoint: &str, model: &str, allowed_paths: &str) -> String {
format!(
r#"# Selfware Configuration
# Generated by `selfware init`
endpoint = "{}"
model = "{}"
# Token limits. `context_length` MUST match your server's real context window
# (vLLM --max-model-len, LM Studio's context slider, Ollama num_ctx); raise it
# when your model serves more. `max_tokens` is the per-response output budget
# and must fit inside context_length with room to spare for conversation.
# Leaving these unset is NOT safe for models selfware doesn't recognize: the
# context window falls back to a conservative 32k while max_tokens defaults
# to 64k, which doesn't fit.
context_length = 32768
max_tokens = 8192
# Execution mode is chosen per RUN, not stored here — a config file (this one
# lives in the repo) must never be able to silently enable auto-approval.
# Start Selfware in your chosen mode with a flag or env var:
# Normal (ask first): selfware
# AutoEdit: selfware -m auto-edit (or SELFWARE_MODE=auto-edit)
# YOLO (auto all): selfware -y (or SELFWARE_MODE=yolo)
[safety]
allowed_paths = {}
[agent]
# token_budget defaults to 60% of context_length — set explicitly to override
# token_budget = 19660
"#,
endpoint, model, allowed_paths
)
}
fn probe_endpoint_tcp(endpoint: &str, timeout: std::time::Duration) -> bool {
use std::net::{TcpStream, ToSocketAddrs};
let Ok(url) = url::Url::parse(endpoint) else {
return false;
};
let Some(host) = url.host_str() else {
return false;
};
let Some(port) = url.port_or_known_default() else {
return false;
};
let Ok(mut addrs) = (host, port).to_socket_addrs() else {
return false;
};
addrs.any(|addr| TcpStream::connect_timeout(&addr, timeout).is_ok())
}
fn write_config_file(endpoint: &str, model: &str, mode: &str, allowed_paths: &str) -> Result<()> {
use std::path::PathBuf;
let config_path = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("selfware.toml");
if config_path.exists() {
use std::io::{self, BufRead, Write};
wizard_print!(
" {} Configuration already exists at {}",
Glyphs::frost(),
config_path.display()
);
print!(" Overwrite? [y/N]: ");
io::stdout().flush()?;
let mut answer = String::new();
io::stdin().lock().read_line(&mut answer)?;
if !answer.trim().eq_ignore_ascii_case("y") {
wizard_print!(" Aborted. Existing configuration preserved.");
return Ok(());
}
}
let content = build_config_content(endpoint, model, allowed_paths);
crate::config::Config::validate_generated_toml(&content)?;
let endpoint_reachable = probe_endpoint_tcp(endpoint, std::time::Duration::from_secs(2));
std::fs::write(&config_path, &content)?;
wizard_print!(
" {} Configuration saved to {}",
Glyphs::bloom(),
config_path.display()
);
match crate::config::trust::add_trusted_config(&config_path) {
Ok(()) => wizard_print!(
" {} Trusted this config — its [safety] settings take effect on the next run.",
Glyphs::bloom()
),
Err(e) => {
wizard_print!(
" {} Could not record repo trust ({}). Run `selfware trust` in this directory,",
Glyphs::frost(),
e
);
wizard_print!(
" otherwise the untrusted-checkout protection resets allowed_paths/hooks/MCP to defaults."
);
}
}
if let Some(home_config) = dirs::home_dir().map(|h| h.join(".config/selfware/config.toml")) {
if home_config.is_file() {
wizard_print!(
" {} Note: {} takes precedence over your global config ({}) in this directory.",
Glyphs::frost(),
config_path.display(),
home_config.display()
);
}
}
if endpoint_reachable {
wizard_print!(" {} Endpoint {} is reachable.", Glyphs::bloom(), endpoint);
} else {
wizard_print!();
wizard_print!(
" {} WARNING: could not reach {} — the config was written UNVERIFIED.",
Glyphs::frost(),
endpoint
);
wizard_print!(
" Start your LLM server (or fix the endpoint), then verify with `selfware llm-doctor`."
);
}
wizard_print!();
match mode {
"yolo" => wizard_print!(
" {} You chose YOLO — start it with `selfware -y` (mode isn't saved in config, for safety).",
Glyphs::sprout()
),
"autoedit" | "auto-edit" | "auto_edit" => wizard_print!(
" {} You chose AutoEdit — start it with `selfware -m auto-edit`.",
Glyphs::sprout()
),
_ => wizard_print!(
" {} Run `selfware` to start your workshop!",
Glyphs::sprout()
),
}
Ok(())
}
#[cfg(test)]
#[path = "../../tests/unit/cli/init_wizard/init_wizard_test.rs"]
mod tests;