1use crate::config;
4use crate::context;
5use crate::tools::shell::redact_secrets;
6use clap::{Args, Subcommand};
7use std::fs;
8use std::io::{self, BufRead, Write};
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12use crate::cli::{Cli, WebSearchMode};
13
14#[derive(Clone, Debug, Eq, PartialEq, Subcommand)]
16pub enum ConfigCommand {
17 Path,
19 Show(ConfigShowCommand),
21 Edit(ConfigEditCommand),
23}
24
25#[derive(Clone, Debug, Eq, PartialEq, Args)]
27pub struct ConfigShowCommand {
28 #[arg(long)]
30 pub redacted: bool,
31}
32
33#[derive(Clone, Debug, Eq, PartialEq, Args)]
35pub struct ConfigEditCommand {
36 #[arg(long, conflicts_with = "project")]
38 pub global: bool,
39 #[arg(long, conflicts_with = "global")]
41 pub project: bool,
42}
43
44pub fn run(cli: &Cli, command: &ConfigCommand) -> io::Result<()> {
46 let stdout = io::stdout();
47 let mut lock = stdout.lock();
48 run_with_writer(cli, command, &mut lock)
49}
50
51pub fn run_with_writer<W: Write>(cli: &Cli, command: &ConfigCommand, writer: &mut W) -> io::Result<()> {
53 match command {
54 ConfigCommand::Path => run_config_path(cli, writer),
55 ConfigCommand::Show(command) => run_config_show(cli, command, writer),
56 ConfigCommand::Edit(command) => run_config_edit(cli, command, writer),
57 }
58}
59
60fn run_config_path<W: Write>(cli: &Cli, writer: &mut W) -> io::Result<()> {
61 let workspace = context::discover_workspace_root(&cli.cwd);
62 let global_path = match config::global_config_path() {
63 Some(path) => config::global_config_path_display(&path),
64 None => String::from("<not available>"),
65 };
66 let project_path = config::project_config_path(&workspace);
67
68 writeln!(writer, "global: {global_path}")?;
69 writeln!(
70 writer,
71 "project: {}",
72 config::project_config_path_display(&project_path, &workspace),
73 )?;
74 Ok(())
75}
76
77fn run_config_show<W: Write>(cli: &Cli, command: &ConfigShowCommand, writer: &mut W) -> io::Result<()> {
78 if !command.redacted {
79 return Err(io::Error::new(
80 io::ErrorKind::InvalidInput,
81 "thndrs config show requires --redacted for safe output",
82 ));
83 }
84
85 let workspace = context::discover_workspace_root(&cli.cwd);
86 let env_vars: Vec<(String, String)> = std::env::vars().collect();
87 let effective = config::load_effective(&workspace, &env_vars).map_err(io::Error::other)?;
88 let effective_config = effective.config.redacted();
89
90 writeln!(writer, "effective_config:")?;
91 write_config(&effective_config, writer)?;
92
93 writeln!(writer, "loaded_files:")?;
94 if effective.layers.is_empty() {
95 writeln!(writer, " <none>")?;
96 } else {
97 for layer in &effective.layers {
98 let path = layer.display_path.as_deref().unwrap_or("<none>");
99 let hash = layer.hash.as_deref().unwrap_or("<none>");
100 writeln!(writer, " {}: {} ({hash})", layer.source.as_str(), path)?;
101 }
102 }
103
104 writeln!(writer, "origins:")?;
105 for (key, origin) in &effective.origins {
106 writeln!(writer, " {key}: {}: {}", origin.source.as_str(), origin.detail)?;
107 }
108
109 writeln!(writer, "diagnostics:")?;
110 if effective.diagnostics.is_empty() {
111 writeln!(writer, " <none>")?;
112 } else {
113 for diagnostic in &effective.diagnostics {
114 writeln!(writer, " - {}", redact_secrets(diagnostic))?;
115 }
116 }
117
118 Ok(())
119}
120
121fn run_config_edit<W: Write>(cli: &Cli, command: &ConfigEditCommand, writer: &mut W) -> io::Result<()> {
122 let (scope, path) = select_config_path(cli, command)?;
123 let mut input = io::stdin().lock();
124 ensure_parent_directory(command, &path, &mut input, writer)?;
125
126 if !path.exists() {
127 fs::write(&path, "").map_err(|error| {
128 io::Error::new(
129 error.kind(),
130 format!("failed to create {} config file {}: {error}", scope, path.display()),
131 )
132 })?;
133 }
134
135 let editor = resolve_editor(std::env::var("EDITOR").ok(), scope, &path, writer)?;
136 launch_editor(&editor, &path)
137}
138
139fn resolve_editor<W: Write>(
140 editor: Option<String>, scope: ConfigScope, path: &Path, writer: &mut W,
141) -> io::Result<String> {
142 editor.filter(|editor| !editor.trim().is_empty()).ok_or_else(|| {
143 let _ = writeln!(writer, "cannot open {scope} config file because EDITOR is not set");
144 let _ = writeln!(writer, "set EDITOR to your preferred editor and re-run this command");
145 let _ = writeln!(writer, "path: {}", path.display());
146 io::Error::new(io::ErrorKind::NotFound, "$EDITOR is not set")
147 })
148}
149
150fn select_config_path(cli: &Cli, command: &ConfigEditCommand) -> io::Result<(ConfigScope, PathBuf)> {
151 let workspace = context::discover_workspace_root(&cli.cwd);
152
153 match (command.global, command.project) {
154 (true, false) => config::global_config_path()
155 .map(|path| (ConfigScope::Global, path))
156 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not available")),
157 (false, true) => Ok((ConfigScope::Project, config::project_config_path(&workspace))),
158 (true, true) => Err(io::Error::new(
159 io::ErrorKind::InvalidInput,
160 "--global and --project cannot both be set",
161 )),
162 (false, false) => Err(io::Error::new(
163 io::ErrorKind::InvalidInput,
164 "choose one of --global or --project for config edit",
165 )),
166 }
167}
168
169#[derive(Clone, Copy, Debug, Eq, PartialEq)]
170enum ConfigScope {
171 Global,
172 Project,
173}
174
175impl std::fmt::Display for ConfigScope {
176 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 match self {
178 Self::Global => f.write_str("global"),
179 Self::Project => f.write_str("project"),
180 }
181 }
182}
183
184fn ensure_parent_directory<W: Write, R: BufRead>(
185 command: &ConfigEditCommand, path: &Path, input: &mut R, writer: &mut W,
186) -> io::Result<()> {
187 let parent = match path.parent() {
188 Some(parent) => parent,
189 None => return Ok(()),
190 };
191
192 if parent.exists() {
193 return Ok(());
194 }
195
196 let scope = if command.global { "global" } else { "project" };
197 writeln!(writer, "{scope} config directory does not exist: {}", parent.display())?;
198 if !confirm_parent_creation(input, writer)? {
199 return Err(io::Error::new(
200 io::ErrorKind::InvalidInput,
201 "parent directory creation declined",
202 ));
203 }
204
205 fs::create_dir_all(parent).map_err(|error| {
206 io::Error::new(
207 error.kind(),
208 format!("failed to create config directory {}: {error}", parent.display()),
209 )
210 })?;
211
212 Ok(())
213}
214
215fn confirm_parent_creation<W: Write, R: BufRead>(input: &mut R, writer: &mut W) -> io::Result<bool> {
216 write!(writer, "create it before opening the editor? [y/N]: ")?;
217 writer.flush()?;
218
219 let mut response = String::new();
220 input.read_line(&mut response).map_err(io::Error::other)?;
221 let normalized = response.trim().to_ascii_lowercase();
222 if normalized.is_empty() { Ok(false) } else { Ok(normalized == "y" || normalized == "yes") }
223}
224
225fn launch_editor(editor: &str, path: &Path) -> io::Result<()> {
226 let mut parts = editor.split_whitespace();
227 let program = parts
228 .next()
229 .filter(|value| !value.is_empty())
230 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "$EDITOR is invalid: empty command"))?;
231
232 let status = Command::new(program)
233 .args(parts)
234 .arg(path)
235 .status()
236 .map_err(io::Error::other)?;
237 if status.success() {
238 return Ok(());
239 }
240
241 Err(io::Error::other(format!(
242 "editor exited with status {}",
243 status
244 .code()
245 .map(|code| code.to_string())
246 .unwrap_or_else(|| String::from("aborted"))
247 )))
248}
249
250fn write_config<W: Write>(config: &config::Config, writer: &mut W) -> io::Result<()> {
251 writeln!(writer, " model: {}", config.model.as_deref().unwrap_or("<unset>"))?;
252 writeln!(
253 writer,
254 " websearch: {}",
255 config.websearch.unwrap_or(WebSearchMode::DuckDuckGo).label()
256 )?;
257 writeln!(
258 writer,
259 " reasoning_effort: {}",
260 config.reasoning_effort.unwrap_or_default().label()
261 )?;
262 writeln!(
263 writer,
264 " reasoning_summary: {}",
265 config.reasoning_summary.unwrap_or_default().label()
266 )?;
267 writeln!(writer, " tick_rate_ms: {}", config.tick_rate_ms.unwrap_or(0))?;
268 writeln!(writer, " mouse: {}", config.mouse.unwrap_or(false))?;
269 writeln!(writer, " verbose: {}", config.verbose.unwrap_or(false))?;
270 writeln!(writer, " theme: {:?}", config.theme.unwrap_or_default())?;
271 if config.skill_dirs.is_empty() {
272 writeln!(writer, " skill_dirs: []")?;
273 } else {
274 writeln!(writer, " skill_dirs:")?;
275 for dir in &config.skill_dirs {
276 writeln!(writer, " {}", dir.display())?;
277 }
278 }
279
280 if let Some(session_dir) = &config.session_dir {
281 writeln!(writer, " session_dir: {}", session_dir.display())?;
282 } else {
283 writeln!(writer, " session_dir: <unset>")?;
284 }
285
286 if let Some(default_workspace) = &config.default_workspace {
287 writeln!(writer, " default_workspace: {}", default_workspace.display())?;
288 } else {
289 writeln!(writer, " default_workspace: <unset>")?;
290 }
291
292 if config.acp_agents.is_empty() {
293 writeln!(writer, " acp_agents: []")?;
294 } else {
295 writeln!(writer, " acp_agents:")?;
296 for (name, agent) in &config.acp_agents {
297 writeln!(writer, " {name}:")?;
298 writeln!(writer, " command: {}", agent.command)?;
299 writeln!(writer, " args: {:?}", agent.args)?;
300 if agent.env.is_empty() {
301 writeln!(writer, " env: []")?;
302 } else {
303 writeln!(writer, " env:")?;
304 for (key, value) in &agent.env {
305 writeln!(writer, " {key} = {value}")?;
306 }
307 }
308 writeln!(writer, " enabled: {}", agent.enabled)?;
309 writeln!(writer, " timeout_secs: {}", agent.timeout_secs)?;
310 }
311 }
312
313 Ok(())
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::cli::commands::config::{ConfigCommand, ConfigShowCommand};
320 use std::io::Cursor;
321
322 fn run_config_output(cli: &Cli, command: &ConfigCommand) -> io::Result<String> {
323 let mut output = Cursor::new(Vec::new());
324 run_with_writer(cli, command, &mut output)?;
325 String::from_utf8(output.into_inner())
326 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "output is not valid UTF-8"))
327 }
328
329 #[test]
330 fn config_path_prints_paths() {
331 let temp = tempfile::tempdir().expect("tempdir");
332 let workspace = temp.path().join("workspace");
333 std::fs::create_dir_all(&workspace).expect("create workspace");
334
335 let cli = Cli::try_parse_from([
336 "thndrs",
337 "--cwd",
338 workspace.to_str().expect("workspace path"),
339 "config",
340 "path",
341 ])
342 .expect("parse config path");
343 let output = run_config_output(&cli, &ConfigCommand::Path).expect("run path");
344
345 assert!(output.contains("global: "));
346 assert!(output.contains("project: .thndrs/config.toml"));
347 }
348
349 #[test]
350 fn config_show_requires_redacted() {
351 let cli = Cli::try_parse_from(["thndrs", "config", "show"]).expect("parse show");
352 let error = run_with_writer(
353 &cli,
354 &ConfigCommand::Show(ConfigShowCommand { redacted: false }),
355 &mut std::io::sink(),
356 )
357 .expect_err("show should require --redacted");
358 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
359 assert!(error.to_string().contains("--redacted"));
360 }
361
362 #[test]
363 fn config_show_masks_acp_env_values() {
364 let tmp = tempfile::tempdir().expect("tempdir");
365 let workspace = tmp.path().join("workspace");
366 std::fs::create_dir_all(workspace.join(".thndrs")).expect("create config dir");
367 std::fs::write(
368 workspace.join(".thndrs").join("config.toml"),
369 r#"
370 [acp_agents.local]
371 command = "agent"
372 env = { FOO = "plain-secret" }
373 "#,
374 )
375 .expect("write config");
376
377 let cli = Cli::try_parse_from([
378 "thndrs",
379 "--cwd",
380 workspace.to_str().expect("workspace path"),
381 "config",
382 "show",
383 "--redacted",
384 ])
385 .expect("parse show");
386 let output =
387 run_config_output(&cli, &ConfigCommand::Show(ConfigShowCommand { redacted: true })).expect("run show");
388
389 assert!(output.contains("effective_config:"));
390 assert!(output.contains("[redacted]"));
391 assert!(!output.contains("plain-secret"));
392 assert!(output.contains("loaded_files:"));
393 assert!(output.contains("origins:"));
394 assert!(output.contains("diagnostics:"));
395 }
396
397 #[test]
398 fn config_edit_reports_missing_editor() {
399 let temp = tempfile::tempdir().expect("tempdir");
400 let path = temp.path().join(".thndrs").join("config.toml");
401 let mut output = Cursor::new(Vec::new());
402 let err = resolve_editor(None, ConfigScope::Project, &path, &mut output);
403 let output = String::from_utf8(output.into_inner()).expect("output utf8");
404
405 assert!(err.is_err());
406 let err = err.expect_err("missing editor error");
407 assert!(matches!(
408 err.kind(),
409 io::ErrorKind::NotFound | io::ErrorKind::InvalidInput
410 ));
411 assert!(output.contains("cannot open project config file because EDITOR is not set"));
412 assert!(output.contains("set EDITOR to your preferred editor and re-run this command"));
413 }
414}