use anyhow::{Context, Result};
use inquire::{Confirm, CustomType, Select, Text};
use wipe_core::model::{Exposure, Settings, Starter};
use wipe_core::GlobalConfig;
use crate::skills::Target;
#[derive(Debug, Clone, Copy)]
pub struct SkillChoice {
pub target: Target,
pub global: bool,
}
#[derive(Debug, Clone)]
pub struct Plan {
pub name: String,
pub starter: Starter,
pub port: u16,
pub expose: Exposure,
pub autoserve: bool,
pub idle_timeout_secs: u64,
pub skill: Option<SkillChoice>,
}
pub fn parse_starter(s: &str) -> Result<Starter> {
match s.trim().to_ascii_lowercase().as_str() {
"standard" | "default" => Ok(Starter::Standard),
"lists" | "lists-only" => Ok(Starter::ListsOnly),
"empty" | "blank" | "none" => Ok(Starter::Empty),
other => anyhow::bail!("unknown starter '{other}' (use standard|lists|empty)"),
}
}
fn default_port(g: &GlobalConfig) -> u16 {
g.default_port
.unwrap_or_else(|| Settings::default().daemon.port)
}
pub fn non_interactive(name: String, starter: Option<Starter>, g: &GlobalConfig) -> Plan {
Plan {
name,
starter: starter.or(g.starter).unwrap_or_default(),
port: default_port(g),
expose: g.default_expose.unwrap_or_default(),
autoserve: g.autoserve.unwrap_or(false),
idle_timeout_secs: g.idle_timeout_secs.unwrap_or(900),
skill: None,
}
}
pub fn wizard(default_name: &str, default_starter: Starter, g: &GlobalConfig) -> Result<Plan> {
println!("\n Let's set up your wipe board. Press Enter to accept a default.\n");
let name = Text::new("[1/6] Board name")
.with_default(default_name)
.prompt()
.map_err(cancel)?;
let starter = match Select::new(
"[2/6] What should the board start with?",
vec![
"Standard - Backlog/Todo/In Progress/Done, plus a few labels",
"Lists only - the standard lists, no labels",
"Empty - a blank board you fill yourself",
],
)
.with_starting_cursor(match default_starter {
Starter::Standard => 0,
Starter::ListsOnly => 1,
Starter::Empty => 2,
})
.raw_prompt()
.map_err(cancel)?
.index
{
1 => Starter::ListsOnly,
2 => Starter::Empty,
_ => Starter::Standard,
};
let port = CustomType::<u16>::new("[3/6] Local UI port")
.with_default(default_port(g))
.with_error_message("Enter a port number between 0 and 65535")
.prompt()
.map_err(cancel)?;
let expose = match Select::new(
"[4/6] How should the UI be reachable?",
vec![
"Local only (recommended)",
"Tailscale network",
"Behind a reverse proxy",
],
)
.raw_prompt()
.map_err(cancel)?
.index
{
1 => Exposure::Tailscale,
2 => Exposure::Proxy,
_ => Exposure::None,
};
let autoserve = Confirm::new("[5/6] Auto-stop the UI server when no one is viewing it?")
.with_default(g.autoserve.unwrap_or(true))
.with_help_message("Keeps zero background overhead when the board isn't open")
.prompt()
.map_err(cancel)?;
let skill = match Select::new(
"[6/6] Install the agent skill (teaches AI agents to drive wipe)?",
vec![
"Project - Claude Code (.claude/skills)",
"Project - cross-tool (.agents/skills)",
"Global - Claude Code (~/.claude/skills)",
"Global - cross-tool (~/.agents/skills)",
"Skip for now (you can run `wipe skill install` later)",
],
)
.raw_prompt()
.map_err(cancel)?
.index
{
0 => Some(SkillChoice {
target: Target::Claude,
global: false,
}),
1 => Some(SkillChoice {
target: Target::Agents,
global: false,
}),
2 => Some(SkillChoice {
target: Target::Claude,
global: true,
}),
3 => Some(SkillChoice {
target: Target::Agents,
global: true,
}),
_ => None,
};
Ok(Plan {
name,
starter,
port,
expose,
autoserve,
idle_timeout_secs: g.idle_timeout_secs.unwrap_or(900),
skill,
})
}
fn step(i: usize, n: usize, label: &str) -> String {
format!("[{i}/{n}] {label}")
}
pub fn global_wizard(g: &GlobalConfig) -> Result<GlobalConfig> {
const N: usize = 11;
println!("\n wipe global setup - these become the defaults for every board on this machine.");
println!(" Press Enter to accept a default.\n");
let mut out = g.clone();
let port = CustomType::<u16>::new(&step(1, N, "Default UI port"))
.with_default(default_port(g))
.with_error_message("Enter a port number between 0 and 65535")
.prompt()
.map_err(cancel)?;
out.default_port = Some(port);
out.default_expose = Some(
match Select::new(
&step(2, N, "How should the UI be reachable by default?"),
vec![
"Local only (recommended)",
"Tailscale network",
"Behind a reverse proxy",
],
)
.with_starting_cursor(match g.default_expose.unwrap_or_default() {
Exposure::None => 0,
Exposure::Tailscale => 1,
Exposure::Proxy => 2,
})
.raw_prompt()
.map_err(cancel)?
.index
{
1 => Exposure::Tailscale,
2 => Exposure::Proxy,
_ => Exposure::None,
},
);
let autoserve = Confirm::new(&step(
3,
N,
"Auto-stop the UI server when no one is viewing it?",
))
.with_default(g.autoserve.unwrap_or(true))
.with_help_message("Zero background overhead when the board isn't open")
.prompt()
.map_err(cancel)?;
out.autoserve = Some(autoserve);
let idle = CustomType::<u64>::new(&step(4, N, "Idle timeout before auto-stop (seconds)"))
.with_default(g.idle_timeout_secs.unwrap_or(900))
.with_help_message("Used when auto-stop is on")
.prompt()
.map_err(cancel)?;
out.idle_timeout_secs = Some(idle);
let autostart = Confirm::new(&step(
5,
N,
"Start wipe automatically at login (always-on, lightweight)?",
))
.with_default(g.autostart.unwrap_or(false))
.with_help_message("Adds a per-user login entry running `wipe serve`")
.prompt()
.map_err(cancel)?;
out.autostart = Some(autostart);
out.starter = Some(
match Select::new(
&step(6, N, "What should new boards start with?"),
vec![
"Standard - lists + a few labels",
"Lists only - the standard lists, no labels",
"Empty - a blank board",
],
)
.with_starting_cursor(match g.starter.unwrap_or_default() {
Starter::Standard => 0,
Starter::ListsOnly => 1,
Starter::Empty => 2,
})
.raw_prompt()
.map_err(cancel)?
.index
{
1 => Starter::ListsOnly,
2 => Starter::Empty,
_ => Starter::Standard,
},
);
match Select::new(
&step(7, N, "Preferred agent-skill convention?"),
vec![
"Claude Code (.claude/skills)",
"Cross-tool (.agents/skills)",
"Skip",
],
)
.raw_prompt()
.map_err(cancel)?
.index
{
0 => out.skill_target = Some(Target::Claude.slug().to_string()),
1 => out.skill_target = Some(Target::Agents.slug().to_string()),
_ => {}
}
if out.skill_target.is_some() {
out.skill_global = Some(
Confirm::new(" Install skills user-globally by default?")
.with_default(g.skill_global.unwrap_or(false))
.prompt()
.map_err(cancel)?,
);
}
out.ui_theme = Some(
match Select::new(
&step(8, N, "UI theme"),
vec!["Match the system", "Light", "Dark"],
)
.with_starting_cursor(match g.ui_theme.as_deref() {
Some("light") => 1,
Some("dark") => 2,
_ => 0,
})
.raw_prompt()
.map_err(cancel)?
.index
{
1 => "light".to_string(),
2 => "dark".to_string(),
_ => "system".to_string(),
},
);
let accents = ["book-cloth", "kraft", "focus", "sage"];
out.ui_accent = Some(
accents[Select::new(
&step(9, N, "UI accent color"),
vec![
"Book cloth (terracotta)",
"Kraft (tan)",
"Focus (blue)",
"Sage (green)",
],
)
.with_starting_cursor(
accents
.iter()
.position(|a| Some(*a) == g.ui_accent.as_deref())
.unwrap_or(0),
)
.raw_prompt()
.map_err(cancel)?
.index]
.to_string(),
);
let id_default = g
.default_identity
.clone()
.or_else(wipe_core::vcs::system_user)
.unwrap_or_else(|| "human".to_string());
let default_id = Text::new(&step(
10,
N,
"Default identity when your version control reports none",
))
.with_default(&id_default)
.with_help_message("e.g. \"Ada <ada@example.com>\" or just \"human\"")
.prompt()
.map_err(cancel)?;
let default_id = default_id.trim();
out.default_identity = Some(if default_id.is_empty() {
"human".to_string()
} else {
default_id.to_string()
});
out.prefer_default_identity = Some(
Confirm::new(&step(
11,
N,
"Always use that identity, even when version control reports one?",
))
.with_default(g.prefer_default_identity.unwrap_or(false))
.prompt()
.map_err(cancel)?,
);
Ok(out)
}
fn cancel(e: inquire::InquireError) -> anyhow::Error {
use inquire::InquireError::*;
match e {
OperationCanceled | OperationInterrupted => anyhow::anyhow!("onboarding cancelled"),
other => anyhow::Error::new(other).context("onboarding prompt failed"),
}
}
pub fn remember(plan: &Plan) -> Result<()> {
let mut g = GlobalConfig::load();
g.default_port = Some(plan.port);
g.default_expose = Some(plan.expose);
g.autoserve = Some(plan.autoserve);
g.idle_timeout_secs = Some(plan.idle_timeout_secs);
g.starter = Some(plan.starter);
if let Some(s) = &plan.skill {
g.skill_target = Some(s.target.slug().to_string());
g.skill_global = Some(s.global);
}
g.save().context("saving global config")?;
Ok(())
}