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_manifest::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
47/// How the profile for this invocation was chosen.
48///
49/// `Cli` and `Env` are explicit: the operator named the profile. `Session`
50/// and `Discovery` are implicit: the profile came from the stored active
51/// session or from the only profile directory present. Only an explicit
52/// source may aim a mutating command at a cloud profile, and only the
53/// session source may rewrite the active session.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum ProfileSource {
56    Cli,
57    Env,
58    Session,
59    Discovery,
60}
61
62impl ProfileSource {
63    #[must_use]
64    pub const fn is_explicit(self) -> bool {
65        matches!(self, Self::Cli | Self::Env)
66    }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ResolvedProfile {
71    pub path: PathBuf,
72    pub source: ProfileSource,
73}
74
75pub fn resolve_profile_path(
76    cli_override: Option<&str>,
77    env_override: Option<&str>,
78    from_session: Option<PathBuf>,
79) -> Result<ResolvedProfile, ProfileResolutionError> {
80    if let Some(profile_input) = cli_override {
81        return resolve_profile_input(profile_input).map(|path| ResolvedProfile {
82            path,
83            source: ProfileSource::Cli,
84        });
85    }
86
87    if let Some(path_str) = env_override {
88        return resolve_profile_input(path_str).map(|path| ResolvedProfile {
89            path,
90            source: ProfileSource::Env,
91        });
92    }
93
94    if let Some(path) = from_session.filter(|p| p.exists()) {
95        return Ok(ResolvedProfile {
96            path,
97            source: ProfileSource::Session,
98        });
99    }
100
101    let mut profiles = discover_profiles()?;
102    match profiles.len() {
103        0 => Err(ProfileResolutionError::NoProfilesFound),
104        1 => Ok(ResolvedProfile {
105            path: profiles.swap_remove(0).path,
106            source: ProfileSource::Discovery,
107        }),
108        _ => Err(ProfileResolutionError::MultipleProfilesFound {
109            profiles: profiles.iter().map(|p| p.name.clone()).collect(),
110        }),
111    }
112}
113
114pub fn is_path_input(input: &str) -> bool {
115    let path = Path::new(input);
116    let has_yaml_extension = path
117        .extension()
118        .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"));
119
120    input.contains(std::path::MAIN_SEPARATOR)
121        || input.contains('/')
122        || has_yaml_extension
123        || input.starts_with('.')
124        || input.starts_with('~')
125}
126
127fn resolve_profile_input(input: &str) -> Result<PathBuf, ProfileResolutionError> {
128    if is_path_input(input) {
129        return resolve_profile_from_path(input);
130    }
131    resolve_profile_by_name(input)?
132        .ok_or_else(|| ProfileResolutionError::ProfileNotFound(input.to_owned()))
133}
134
135pub fn resolve_profile_from_path(path_str: &str) -> Result<PathBuf, ProfileResolutionError> {
136    let path = expand_path(path_str);
137
138    if path.exists() {
139        return Ok(path);
140    }
141
142    let profile_yaml = path.join("profile.yaml");
143    if profile_yaml.exists() {
144        return Ok(profile_yaml);
145    }
146
147    Err(ProfileResolutionError::ProfileNotFound(path_str.to_owned()))
148}
149
150fn expand_path(path_str: &str) -> PathBuf {
151    if path_str.starts_with('~')
152        && let Some(home) = dirs::home_dir()
153    {
154        return home.join(
155            path_str
156                .strip_prefix("~/")
157                .unwrap_or_else(|| &path_str[1..]),
158        );
159    }
160    PathBuf::from(path_str)
161}
162
163pub fn resolve_profile_with_data(
164    profile_input: &str,
165) -> Result<(PathBuf, Profile), ProfileResolutionError> {
166    let path = resolve_profile_input(profile_input)?;
167    let profile = ProfileLoader::load_from_path(&path)
168        .map_err(|e| ProfileResolutionError::DiscoveryFailed(anyhow::Error::from(e)))?;
169    Ok((path, profile))
170}
171
172fn resolve_profile_by_name(name: &str) -> Result<Option<PathBuf>, ProfileResolutionError> {
173    let ctx = ProjectContext::discover();
174    let profiles_dir = ctx.profiles_dir();
175    let target_dir = profiles_dir.join(name);
176    let config_path = ProfilePath::Config.resolve(&target_dir);
177
178    if config_path.exists() {
179        return Ok(Some(config_path));
180    }
181
182    let profiles = discover_profiles()?;
183    if let Some(found) = profiles.into_iter().find(|p| p.name == name) {
184        return Ok(Some(found.path));
185    }
186
187    {
188        let paths = crate::paths::ResolvedPaths::discover().sessions_dir();
189        let store = systemprompt_cloud::SessionStore::load_or_create(&paths)?;
190        if let Some(session) = store.find_by_profile_name(name)
191            && let Some(ref profile_path) = session.profile_path
192            && profile_path.exists()
193        {
194            return Ok(Some(profile_path.clone()));
195        }
196    }
197
198    Ok(None)
199}
200
201#[derive(Debug)]
202pub struct DiscoveredProfile {
203    pub name: String,
204    pub path: PathBuf,
205    pub profile: Profile,
206}
207
208pub fn discover_profiles() -> Result<Vec<DiscoveredProfile>> {
209    let ctx = ProjectContext::discover();
210    let profiles_dir = ctx.profiles_dir();
211
212    if !profiles_dir.exists() {
213        return Ok(Vec::new());
214    }
215
216    let entries = std::fs::read_dir(&profiles_dir).with_context(|| {
217        format!(
218            "Failed to read profiles directory: {}",
219            profiles_dir.display()
220        )
221    })?;
222
223    let profiles = entries
224        .filter_map(std::result::Result::ok)
225        .filter(|e| e.path().is_dir())
226        .filter_map(|e| build_discovered_profile(&e))
227        .collect();
228
229    Ok(profiles)
230}
231
232fn build_discovered_profile(entry: &std::fs::DirEntry) -> Option<DiscoveredProfile> {
233    let profile_yaml = ProfilePath::Config.resolve(&entry.path());
234    if !profile_yaml.exists() {
235        return None;
236    }
237
238    let name = entry.file_name().to_string_lossy().to_string();
239    let profile = ProfileLoader::load_from_path(&profile_yaml)
240        .map_err(|e| tracing::warn!(profile = %name, error = %e, "Skipping unreadable profile during discovery"))
241        .ok()?;
242
243    Some(DiscoveredProfile {
244        name,
245        path: profile_yaml,
246        profile,
247    })
248}
249
250pub fn generate_oauth_at_rest_pepper() -> String {
251    let mut rng = rng();
252    (0..64)
253        .map(|_| rng.sample(Alphanumeric))
254        .map(char::from)
255        .collect()
256}
257
258pub fn save_profile_yaml(profile: &Profile, path: &Path, header: Option<&str>) -> Result<()> {
259    if let Some(parent) = path.parent() {
260        std::fs::create_dir_all(parent)
261            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
262    }
263
264    let yaml = serde_yaml::to_string(profile).context("Failed to serialize profile")?;
265
266    let content = header.map_or_else(|| yaml.clone(), |h| format!("{}\n\n{}", h, yaml));
267
268    std::fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?;
269
270    Ok(())
271}