Skip to main content

systemprompt_cli/shared/
profile.rs

1//! Profile discovery and resolution shared across CLI commands.
2//!
3//! Resolves a profile to its on-disk path and loaded [`Profile`] from a CLI
4//! override, an environment variable, a stored session, or directory
5//! discovery, reporting failures via [`ProfileResolutionError`]. Also provides
6//! profile-authoring helpers ([`save_profile_yaml`], display-name and pepper
7//! generation) used by the profile-creation flows.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use std::path::{Path, PathBuf};
13
14use anyhow::{Context, Result};
15use rand::distr::Alphanumeric;
16use rand::{RngExt, rng};
17use systemprompt_cloud::{ProfilePath, ProjectContext};
18use systemprompt_loader::ProfileLoader;
19use systemprompt_models::Profile;
20
21#[derive(Debug, thiserror::Error)]
22pub enum ProfileResolutionError {
23    #[error(
24        "No profiles found.\n\nCreate a profile with: systemprompt cloud profile create <name>"
25    )]
26    NoProfilesFound,
27
28    #[error(
29        "Profile '{0}' not found.\n\nRun 'systemprompt cloud profile list' to see available \
30         profiles."
31    )]
32    ProfileNotFound(String),
33
34    #[error("Profile discovery failed: {0}")]
35    DiscoveryFailed(#[from] anyhow::Error),
36
37    #[error(
38        "Multiple profiles found: {profiles:?}\n\nUse --profile <name> or 'systemprompt admin \
39         session switch <profile>'"
40    )]
41    MultipleProfilesFound { profiles: Vec<String> },
42
43    #[error(transparent)]
44    SessionStoreCorrupt(#[from] systemprompt_cloud::CloudError),
45}
46
47pub fn resolve_profile_path(
48    cli_override: Option<&str>,
49    env_override: Option<&str>,
50    from_session: Option<PathBuf>,
51) -> Result<PathBuf, ProfileResolutionError> {
52    if let Some(profile_input) = cli_override {
53        return resolve_profile_input(profile_input);
54    }
55
56    if let Some(path_str) = env_override {
57        return resolve_profile_input(path_str);
58    }
59
60    if let Some(path) = from_session.filter(|p| p.exists()) {
61        return Ok(path);
62    }
63
64    let mut profiles = discover_profiles()?;
65    match profiles.len() {
66        0 => Err(ProfileResolutionError::NoProfilesFound),
67        1 => Ok(profiles.swap_remove(0).path),
68        _ => Err(ProfileResolutionError::MultipleProfilesFound {
69            profiles: profiles.iter().map(|p| p.name.clone()).collect(),
70        }),
71    }
72}
73
74pub fn is_path_input(input: &str) -> bool {
75    let path = Path::new(input);
76    let has_yaml_extension = path
77        .extension()
78        .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"));
79
80    input.contains(std::path::MAIN_SEPARATOR)
81        || input.contains('/')
82        || has_yaml_extension
83        || input.starts_with('.')
84        || input.starts_with('~')
85}
86
87fn resolve_profile_input(input: &str) -> Result<PathBuf, ProfileResolutionError> {
88    if is_path_input(input) {
89        return resolve_profile_from_path(input);
90    }
91    resolve_profile_by_name(input)?
92        .ok_or_else(|| ProfileResolutionError::ProfileNotFound(input.to_owned()))
93}
94
95pub fn resolve_profile_from_path(path_str: &str) -> Result<PathBuf, ProfileResolutionError> {
96    let path = expand_path(path_str);
97
98    if path.exists() {
99        return Ok(path);
100    }
101
102    let profile_yaml = path.join("profile.yaml");
103    if profile_yaml.exists() {
104        return Ok(profile_yaml);
105    }
106
107    Err(ProfileResolutionError::ProfileNotFound(path_str.to_owned()))
108}
109
110fn expand_path(path_str: &str) -> PathBuf {
111    if path_str.starts_with('~')
112        && let Some(home) = dirs::home_dir()
113    {
114        return home.join(
115            path_str
116                .strip_prefix("~/")
117                .unwrap_or_else(|| &path_str[1..]),
118        );
119    }
120    PathBuf::from(path_str)
121}
122
123pub fn resolve_profile_with_data(
124    profile_input: &str,
125) -> Result<(PathBuf, Profile), ProfileResolutionError> {
126    let path = resolve_profile_input(profile_input)?;
127    let profile = ProfileLoader::load_from_path(&path)
128        .map_err(|e| ProfileResolutionError::DiscoveryFailed(anyhow::Error::from(e)))?;
129    Ok((path, profile))
130}
131
132fn resolve_profile_by_name(name: &str) -> Result<Option<PathBuf>, ProfileResolutionError> {
133    let ctx = ProjectContext::discover();
134    let profiles_dir = ctx.profiles_dir();
135    let target_dir = profiles_dir.join(name);
136    let config_path = ProfilePath::Config.resolve(&target_dir);
137
138    if config_path.exists() {
139        return Ok(Some(config_path));
140    }
141
142    let profiles = discover_profiles()?;
143    if let Some(found) = profiles.into_iter().find(|p| p.name == name) {
144        return Ok(Some(found.path));
145    }
146
147    {
148        let paths = crate::paths::ResolvedPaths::discover().sessions_dir();
149        let store = systemprompt_cloud::SessionStore::load_or_create(&paths)?;
150        if let Some(session) = store.find_by_profile_name(name)
151            && let Some(ref profile_path) = session.profile_path
152            && profile_path.exists()
153        {
154            return Ok(Some(profile_path.clone()));
155        }
156    }
157
158    Ok(None)
159}
160
161#[derive(Debug)]
162pub struct DiscoveredProfile {
163    pub name: String,
164    pub path: PathBuf,
165    pub profile: Profile,
166}
167
168pub fn discover_profiles() -> Result<Vec<DiscoveredProfile>> {
169    let ctx = ProjectContext::discover();
170    let profiles_dir = ctx.profiles_dir();
171
172    if !profiles_dir.exists() {
173        return Ok(Vec::new());
174    }
175
176    let entries = std::fs::read_dir(&profiles_dir).with_context(|| {
177        format!(
178            "Failed to read profiles directory: {}",
179            profiles_dir.display()
180        )
181    })?;
182
183    let profiles = entries
184        .filter_map(std::result::Result::ok)
185        .filter(|e| e.path().is_dir())
186        .filter_map(|e| build_discovered_profile(&e))
187        .collect();
188
189    Ok(profiles)
190}
191
192fn build_discovered_profile(entry: &std::fs::DirEntry) -> Option<DiscoveredProfile> {
193    let profile_yaml = ProfilePath::Config.resolve(&entry.path());
194    if !profile_yaml.exists() {
195        return None;
196    }
197
198    let name = entry.file_name().to_string_lossy().to_string();
199    let profile = ProfileLoader::load_from_path(&profile_yaml)
200        .map_err(|e| tracing::warn!(profile = %name, error = %e, "Skipping unreadable profile during discovery"))
201        .ok()?;
202
203    Some(DiscoveredProfile {
204        name,
205        path: profile_yaml,
206        profile,
207    })
208}
209
210pub fn generate_oauth_at_rest_pepper() -> String {
211    let mut rng = rng();
212    (0..64)
213        .map(|_| rng.sample(Alphanumeric))
214        .map(char::from)
215        .collect()
216}
217
218pub fn save_profile_yaml(profile: &Profile, path: &Path, header: Option<&str>) -> Result<()> {
219    if let Some(parent) = path.parent() {
220        std::fs::create_dir_all(parent)
221            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
222    }
223
224    let yaml = serde_yaml::to_string(profile).context("Failed to serialize profile")?;
225
226    let content = header.map_or_else(|| yaml.clone(), |h| format!("{}\n\n{}", h, yaml));
227
228    std::fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?;
229
230    Ok(())
231}