1use std::fmt::Write as _;
7use std::path::{Path, PathBuf};
8
9use anyhow::Result;
10use scv_client::Layout;
11
12use crate::config::{Config, ConfigOverrides};
13
14pub fn render(workspace: &Path, overrides: &ConfigOverrides, all: bool) -> Result<String> {
17 let layout = Layout::from_env()?;
18 let home = crate::config::user_home_path()
19 .map_or_else(|| layout.home().to_owned(), |home| home.to_owned());
20 let layout = Layout::new(home);
21 let mut out = String::new();
22 let selected = if std::env::var_os("SCV_HOME").is_some() {
23 "selected by SCV_HOME or --scv-home"
24 } else {
25 "the default instance"
26 };
27 writeln!(out, "SCV instance {} ({selected})", tilde(layout.home()))?;
28
29 writeln!(out, "\nFiles")?;
30 let mut files = vec![
31 (layout.config(), "settings you edit"),
32 (layout.credentials(), "channel sign-ins SCV writes"),
33 (layout.agents(), "private homes of delegated agents"),
34 (layout.skills(), "your skills"),
35 (layout.state(), "runtime state SCV writes; not for editing"),
36 ];
37 if let Some(explicit) = std::env::var_os("SCV_CONFIG") {
38 files.push((PathBuf::from(explicit), "extra settings from SCV_CONFIG"));
39 }
40 let project = workspace.join(".scv/config.toml");
41 if project.is_file()
42 && std::fs::canonicalize(&project).ok() != std::fs::canonicalize(layout.config()).ok()
43 {
44 files.push((project, "project settings for this directory"));
45 }
46 for (path, role) in &files {
47 row(
48 &mut out,
49 &tilde(path),
50 &format!("{} ({role})", describe(path)),
51 )?;
52 }
53 let socket = layout.socket();
54 let daemon = if std::os::unix::net::UnixStream::connect(&socket).is_ok() {
55 "a daemon is listening"
56 } else {
57 "no daemon is listening"
58 };
59 row(&mut out, &tilde(&socket), daemon)?;
60 if let Ok(unit) = crate::service_unit_path() {
61 let state = if unit.exists() { "present" } else { "missing" };
62 row(
63 &mut out,
64 &tilde(&unit),
65 &format!("{state} (daemon service unit)"),
66 )?;
67 }
68
69 writeln!(out, "\nSettings (value, then where it was set)")?;
70 let mut active = None;
71 match Config::settings_with_origins(Some(workspace), overrides) {
72 Ok(settings) => {
73 active = settings
74 .iter()
75 .find(|setting| setting.key == "provider.active")
76 .map(|setting| setting.value.trim_matches('"').to_owned());
77 let hidden = settings
78 .iter()
79 .filter(|setting| !all && setting.origin == "default")
80 .count();
81 for setting in settings
82 .iter()
83 .filter(|setting| all || setting.origin != "default")
84 {
85 writeln!(
86 out,
87 " {} = {} [{}]",
88 setting.key, setting.value, setting.origin
89 )?;
90 }
91 if hidden > 0 {
92 writeln!(
93 out,
94 " ({hidden} more at their defaults; `scv config show --all` lists them)"
95 )?;
96 }
97 }
98 Err(error) => writeln!(out, " unreadable: {}", safe_error(&error))?,
99 }
100 match Config::load(workspace, overrides.clone()) {
101 Ok(config) => {
102 let provider = &config.provider;
103 let key = match (&provider.api_key, &provider.api_key_env) {
104 (Some(key), _) if !key.trim().is_empty() => "api_key in the settings".to_owned(),
105 (_, Some(variable)) if std::env::var_os(variable).is_some() => {
106 format!("${variable}, set here")
107 }
108 (_, Some(variable)) => format!("${variable}, which is not set in this shell"),
109 _ => "none".to_owned(),
110 };
111 let name = active.map_or_else(
112 || "[provider]".to_owned(),
113 |name| format!("profile {name:?}"),
114 );
115 writeln!(
116 out,
117 " In effect: {name}, model {:?} at {:?} ({} API), key from {key}",
118 provider.model, provider.base_url, provider.wire_api
119 )?;
120 }
121 Err(error) => writeln!(out, " Invalid: {}", safe_error(&error))?,
122 }
123
124 writeln!(
125 out,
126 "\nChannels ([channels.<channel>.<account>] in config.toml)"
127 )?;
128 let mut any = false;
129 let wechat = scv_clawbot::state::Store::new(&layout, scv_clawbot::CHANNEL);
130 any |= channel(&mut out, layout.home(), &wechat, scv_clawbot::CHANNEL)?;
131 let feishu = scv_feishu::state::Store::new(&layout, scv_feishu::CHANNEL);
132 any |= channel(&mut out, layout.home(), &feishu, scv_feishu::CHANNEL)?;
133 if !any {
134 writeln!(out, " none signed in; see `scv channels login`")?;
135 }
136
137 writeln!(
138 out,
139 "\nAgents ([agents.<name>] in config.toml; homes under {})",
140 tilde(&layout.agents())
141 )?;
142 for adapter in scv_tools::adapters::ADAPTERS {
143 let home = layout.agent_home(adapter.name);
144 if !home.is_dir() {
145 row(&mut out, adapter.name, "no home yet (created on first use)")?;
146 continue;
147 }
148 let credentials: Vec<String> = adapter
149 .credential_files
150 .iter()
151 .map(|file| format!("{file} {}", describe(&home.join(file))))
152 .collect();
153 row(&mut out, adapter.name, &credentials.join(", "))?;
154 match crate::agent_import_status(adapter.name) {
155 Ok(Some(line)) => writeln!(out, " {:<32} {line}", "")?,
156 Ok(None) => {}
157 Err(error) => writeln!(
158 out,
159 " {:<32} import record unreadable: {}",
160 "",
161 safe_error(&error)
162 )?,
163 }
164 }
165
166 let strays = layout.strays()?;
167 if !strays.is_empty() {
168 writeln!(out, "\nNot used by SCV")?;
169 for stray in strays {
170 let note = if stray.legacy {
171 "left by an older SCV layout"
172 } else {
173 "not part of the SCV layout"
174 };
175 row(&mut out, &tilde(&stray.path), note)?;
176 }
177 }
178 Ok(out)
179}
180
181fn channel<C: scv_channels::state::Credentials>(
183 out: &mut String,
184 home: &Path,
185 store: &scv_channels::state::Store<C>,
186 name: &str,
187) -> Result<bool> {
188 let mut accounts = store.account_names().unwrap_or_default();
189 match store.configured_accounts() {
190 Ok(configured) => accounts.extend(configured),
191 Err(error) => writeln!(out, " {name}: settings unreadable: {}", safe_error(&error))?,
192 }
193 accounts.sort();
194 accounts.dedup();
195 for account in &accounts {
196 let (credentials, settings) = store.inspect(account);
197 let settings = match settings {
198 Ok(settings) => {
199 let workspace = settings
200 .workspace
201 .as_deref()
202 .map_or_else(|| "the daemon's workspace".to_owned(), tilde);
203 let tools = match settings.remote_tools {
204 scv_channels::state::RemoteTools::None => "no remote tools",
205 scv_channels::state::RemoteTools::Owner => "owner has remote tools",
206 };
207 let enabled = if settings.enabled {
208 "enabled"
209 } else {
210 "disabled"
211 };
212 format!("{enabled}, {tools}, workspace {workspace}")
213 }
214 Err(error) => format!("invalid settings: {}", safe_error(&error)),
215 };
216 row(out, &format!("{name}:{account}"), &settings)?;
217 let path = store.credentials_path(account)?;
218 let credentials = match credentials {
219 Ok(Some(_)) => describe(&path),
220 Ok(None) => "missing: not signed in".into(),
221 Err(_) => format!("{} but unreadable; log in again", describe(&path)),
222 };
223 let shown = path.strip_prefix(home).unwrap_or(&path);
224 writeln!(
225 out,
226 " {:<32} credentials {} {credentials}",
227 "",
228 shown.display()
229 )?;
230 }
231 Ok(!accounts.is_empty())
232}
233
234fn row(out: &mut String, left: &str, right: &str) -> std::fmt::Result {
235 writeln!(out, " {left:<32} {right}")
236}
237
238fn describe(path: &Path) -> String {
240 match std::fs::symlink_metadata(path) {
241 Ok(metadata) => {
242 use std::os::unix::fs::PermissionsExt;
243 let mode = std::fs::metadata(path).map_or(metadata.permissions().mode(), |target| {
244 target.permissions().mode()
245 }) & 0o777;
246 let private = if mode & 0o077 == 0 {
247 ""
248 } else {
249 ", readable by others"
250 };
251 format!("{mode:04o}{private}")
252 }
253 Err(_) => "missing".into(),
254 }
255}
256
257fn tilde(path: &Path) -> String {
259 match dirs::home_dir().and_then(|home| path.strip_prefix(&home).ok().map(Path::to_owned)) {
260 Some(relative) if relative.as_os_str().is_empty() => "~".into(),
261 Some(relative) => format!("~/{}", relative.display()),
262 None => path.display().to_string(),
263 }
264}
265
266fn safe_error(error: &anyhow::Error) -> String {
269 error.to_string().replace('\n', " ")
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn modes_and_missing_files_are_described() {
278 let dir = tempfile::tempdir().unwrap();
279 let file = dir.path().join("file");
280 std::fs::write(&file, "").unwrap();
281 use std::os::unix::fs::PermissionsExt;
282 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
283 assert_eq!(describe(&file), "0600");
284 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
285 assert_eq!(describe(&file), "0644, readable by others");
286 assert_eq!(describe(&dir.path().join("missing")), "missing");
287 }
288}