use std::path::Path;
pub const DEFAULT_CONFIG: &str = "\
# ~/.config/hotl/config.toml — the single hotl config file.
# Docs: https://nrakochy.github.io/hotl/
# Every hand-editable setting lives here. Env vars override these (HOTL_MODEL,
# ANTHROPIC_API_KEY / OPENAI_API_KEY, HOTL_OPENAI_BASE_URL, HOTL_SANDBOX=off).
[provider]
# provider/model. `openai/…` covers any OpenAI-compatible endpoint.
# model = \"openai/gpt-5\"
# base_url = \"http://localhost:11434/v1\" # e.g. Ollama (openai provider)
# fast_model = \"...\" # cheap model for compaction summaries
# Run a command to obtain the API key (stdout = key). Beats the static env
# key when set. For gateways with short-lived keys, see
# https://nrakochy.github.io/hotl/gateway/
# api_key_helper = \"my-mint-key\"
# api_key_helper_ttl_secs = 300
[context]
# window = 200000 # your model's context size, in tokens
# evict_tokens = 20000 # offload tool results larger than this (0 disables)
# compaction_reset = false # fresh-slate compaction instead of in-place
# show_used_pct = true # show context-fullness in each turn's status
[behavior]
# sandbox = true # false disables the bash sandbox floor
# vim_mode = true # vim-style keys in the console's input editor
[permissions]
mode = \"auto\" # no per-action y/N; protected paths + sandbox still guard.
# \"ask\" = approve every mutating/executing call. A
# security-enforced build ignores this key entirely.
[skills]
# claude = true # false stops reading Claude Code skills (~/.claude/skills
# and the plugin cache) alongside the hotl skills dir.
[network]
# Egress for bash commands: \"open\" (default), \"off\" (loopback + unix sockets
# only), or \"allowlist\" (loopback + the hosts below, via a local proxy).
# egress = \"open\"
# allow = [\"github.com\", \"*.crates.io\"]
[retention]
# Prune old sessions/shadows/blobs (run `hotl gc`, or auto at startup once set).
# max_age_days = 30
# max_sessions = 200
# --- allow-rules: auto-approve trusted tool calls (TRUST GRANTS, not scopes) ---
# Anything not matched still asks. Protected paths (ssh/creds/build.rs/git hooks)
# ALWAYS ask, rule or not. Read-only conveniences are safe to auto-allow:
[[allow]]
tool = \"bash\"
prefix = \"ls \"
[[allow]]
tool = \"bash\"
prefix = \"cat \"
[[allow]]
tool = \"bash\"
prefix = \"git status\"
[[allow]]
tool = \"bash\"
prefix = \"git diff\"
[[allow]]
tool = \"bash\"
prefix = \"git log\"
# build/test runs code — opt in deliberately:
# [[allow]]
# tool = \"bash\"
# prefix = \"cargo test\"
#
# [[allow]]
# tool = \"edit\"
# path_prefix = \"src/\"
# --- MCP tool servers (first use asks, shows the binary + hash) ---
# [[mcp]]
# name = \"docs\"
# command = \"/usr/local/bin/docs-mcp\"
# args = [\"--stdio\"]
# description = \"project documentation search\"
# --- post-edit diagnostics: run your check command after edits ---
# [diagnostics]
# rs = \"cargo check -q --message-format=short\"
# --- hooks: intercept tool calls (pre_tool: deny/rewrite; post_tool: replace) ---
# [[hook]]
# event = \"pre_tool\"
# command = \"/usr/local/bin/guard\"
";
pub fn setup_main(config_dir: &Path, force: bool) -> i32 {
if let Err(e) = std::fs::create_dir_all(config_dir) {
eprintln!("hotl: could not create {}: {e}", config_dir.display());
return 1;
}
let cfg = config_dir.join("config.toml");
match write_if_absent(&cfg, DEFAULT_CONFIG, force) {
Wrote::Created => println!("wrote {}", cfg.display()),
Wrote::Exists => println!("kept existing {} (use --force to overwrite)", cfg.display()),
Wrote::Failed(e) => {
eprintln!("hotl: could not write {}: {e}", cfg.display());
return 1;
}
}
println!(
"config dir: {}\nnext: set a provider in [provider] (or HOTL_MODEL + a key), \
then `hotl doctor` to verify.",
config_dir.display()
);
0
}
enum Wrote {
Created,
Exists,
Failed(std::io::Error),
}
fn write_if_absent(path: &Path, content: &str, force: bool) -> Wrote {
if path.exists() && !force {
return Wrote::Exists;
}
match std::fs::write(path, content) {
Ok(()) => Wrote::Created,
Err(e) => Wrote::Failed(e),
}
}
pub fn first_run_hint(config_dir: &Path) -> Option<String> {
if config_dir.exists() {
return None;
}
Some(format!(
"first run — no config at {}. `hotl setup` writes safe defaults; `hotl doctor` checks your setup.",
config_dir.display()
))
}
pub fn expand_file_refs(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find("@[") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let Some(end) = after.find(']') else {
out.push_str(&rest[start..]);
return out;
};
let path = &after[..end];
match std::fs::read_to_string(path) {
Ok(content) => {
out.push_str(&format!("\n<file path=\"{path}\">\n{content}\n</file>\n"));
}
Err(e) => out.push_str(&format!("@[{path}] (could not read: {e})")),
}
rest = &after[end + 1..];
}
out.push_str(rest);
out
}
pub fn is_newer(current: &str, latest: &str) -> bool {
parts(latest) > parts(current)
}
fn parts(v: &str) -> (u64, u64, u64) {
let v = v.trim_start_matches('v');
let mut it = v
.split(['.', '-', '+'])
.map(|p| p.parse::<u64>().unwrap_or(0));
(
it.next().unwrap_or(0),
it.next().unwrap_or(0),
it.next().unwrap_or(0),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn setup_writes_then_preserves() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("hotl");
assert_eq!(setup_main(&cfg, false), 0);
assert!(cfg.join("config.toml").exists());
std::fs::write(cfg.join("config.toml"), "# edited\n").unwrap();
setup_main(&cfg, false);
assert_eq!(
std::fs::read_to_string(cfg.join("config.toml")).unwrap(),
"# edited\n"
);
setup_main(&cfg, true);
assert!(std::fs::read_to_string(cfg.join("config.toml"))
.unwrap()
.contains("allow"));
}
#[test]
fn first_run_hint_only_when_absent() {
let dir = tempfile::tempdir().unwrap();
assert!(first_run_hint(&dir.path().join("nope")).is_some());
assert!(first_run_hint(dir.path()).is_none());
}
#[test]
fn file_refs_expand_and_degrade() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("note.txt");
std::fs::write(&f, "hello from file").unwrap();
let prompt = format!("summarize @[{}] please", f.display());
let out = expand_file_refs(&prompt);
assert!(out.contains("hello from file") && out.contains("<file path="));
assert!(out.starts_with("summarize") && out.trim_end().ends_with("please"));
let missing = expand_file_refs("see @[/no/such/file]");
assert!(missing.contains("could not read"));
assert_eq!(expand_file_refs("plain prompt"), "plain prompt");
assert_eq!(expand_file_refs("oops @[unclosed"), "oops @[unclosed");
}
#[test]
fn version_ordering() {
assert!(is_newer("0.1.2", "0.2.0"));
assert!(is_newer("0.1.2", "0.1.3"));
assert!(is_newer("v0.1.2", "v1.0.0"));
assert!(!is_newer("0.2.0", "0.1.9"));
assert!(!is_newer("0.1.2", "0.1.2"));
}
}