Skip to main content

scv_server/
overview.rs

1//! `scv config show`: every path an SCV instance uses, the settings in effect
2//! and where each came from, and whether each credential is in place. It
3//! reads files only, takes no lock a running daemon needs, and never prints a
4//! secret.
5
6use std::fmt::Write as _;
7use std::path::{Path, PathBuf};
8
9use anyhow::Result;
10use scv_client::Layout;
11
12use crate::config::{Config, ConfigOverrides};
13
14/// Render the overview for a session started in `workspace`. With `all`,
15/// settings left at their defaults are listed too.
16pub 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
181/// The accounts of one channel; whether there were any.
182fn 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
238/// Whether `path` exists and, on Unix, its permission bits.
239fn 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
257/// `path` with the user's home shown as `~`.
258fn 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
266/// The outermost message of an error. SCV's own messages keep secrets out,
267/// but a parser's inner detail may quote the file, which can hold keys.
268fn 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}