systemprompt_cli/shared/
profile.rs1use 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
44pub fn resolve_profile_path(
45 cli_override: Option<&str>,
46 env_override: Option<&str>,
47 from_session: Option<PathBuf>,
48) -> Result<PathBuf, ProfileResolutionError> {
49 if let Some(profile_input) = cli_override {
50 return resolve_profile_input(profile_input);
51 }
52
53 if let Some(path_str) = env_override {
54 return resolve_profile_input(path_str);
55 }
56
57 if let Some(path) = from_session.filter(|p| p.exists()) {
58 return Ok(path);
59 }
60
61 let mut profiles = discover_profiles()?;
62 match profiles.len() {
63 0 => Err(ProfileResolutionError::NoProfilesFound),
64 1 => Ok(profiles.swap_remove(0).path),
65 _ => Err(ProfileResolutionError::MultipleProfilesFound {
66 profiles: profiles.iter().map(|p| p.name.clone()).collect(),
67 }),
68 }
69}
70
71pub fn is_path_input(input: &str) -> bool {
72 let path = Path::new(input);
73 let has_yaml_extension = path
74 .extension()
75 .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"));
76
77 input.contains(std::path::MAIN_SEPARATOR)
78 || input.contains('/')
79 || has_yaml_extension
80 || input.starts_with('.')
81 || input.starts_with('~')
82}
83
84fn resolve_profile_input(input: &str) -> Result<PathBuf, ProfileResolutionError> {
85 if is_path_input(input) {
86 return resolve_profile_from_path(input);
87 }
88 resolve_profile_by_name(input)?
89 .ok_or_else(|| ProfileResolutionError::ProfileNotFound(input.to_owned()))
90}
91
92pub fn resolve_profile_from_path(path_str: &str) -> Result<PathBuf, ProfileResolutionError> {
93 let path = expand_path(path_str);
94
95 if path.exists() {
96 return Ok(path);
97 }
98
99 let profile_yaml = path.join("profile.yaml");
100 if profile_yaml.exists() {
101 return Ok(profile_yaml);
102 }
103
104 Err(ProfileResolutionError::ProfileNotFound(path_str.to_owned()))
105}
106
107fn expand_path(path_str: &str) -> PathBuf {
108 if path_str.starts_with('~')
109 && let Some(home) = dirs::home_dir()
110 {
111 return home.join(
112 path_str
113 .strip_prefix("~/")
114 .unwrap_or_else(|| &path_str[1..]),
115 );
116 }
117 PathBuf::from(path_str)
118}
119
120pub fn resolve_profile_with_data(
121 profile_input: &str,
122) -> Result<(PathBuf, Profile), ProfileResolutionError> {
123 let path = resolve_profile_input(profile_input)?;
124 let profile = ProfileLoader::load_from_path(&path)
125 .map_err(|e| ProfileResolutionError::DiscoveryFailed(anyhow::Error::from(e)))?;
126 Ok((path, profile))
127}
128
129fn resolve_profile_by_name(name: &str) -> Result<Option<PathBuf>, ProfileResolutionError> {
130 let ctx = ProjectContext::discover();
131 let profiles_dir = ctx.profiles_dir();
132 let target_dir = profiles_dir.join(name);
133 let config_path = ProfilePath::Config.resolve(&target_dir);
134
135 if config_path.exists() {
136 return Ok(Some(config_path));
137 }
138
139 let profiles = discover_profiles()?;
140 if let Some(found) = profiles.into_iter().find(|p| p.name == name) {
141 return Ok(Some(found.path));
142 }
143
144 {
145 let paths = crate::paths::ResolvedPaths::discover().sessions_dir();
146 if let Ok(store) = systemprompt_cloud::SessionStore::load_or_create(&paths)
147 && let Some(session) = store.find_by_profile_name(name)
148 && let Some(ref profile_path) = session.profile_path
149 && profile_path.exists()
150 {
151 return Ok(Some(profile_path.clone()));
152 }
153 }
154
155 Ok(None)
156}
157
158#[derive(Debug)]
159pub struct DiscoveredProfile {
160 pub name: String,
161 pub path: PathBuf,
162 pub profile: Profile,
163}
164
165pub fn discover_profiles() -> Result<Vec<DiscoveredProfile>> {
166 let ctx = ProjectContext::discover();
167 let profiles_dir = ctx.profiles_dir();
168
169 if !profiles_dir.exists() {
170 return Ok(Vec::new());
171 }
172
173 let entries = std::fs::read_dir(&profiles_dir).with_context(|| {
174 format!(
175 "Failed to read profiles directory: {}",
176 profiles_dir.display()
177 )
178 })?;
179
180 let profiles = entries
181 .filter_map(std::result::Result::ok)
182 .filter(|e| e.path().is_dir())
183 .filter_map(|e| build_discovered_profile(&e))
184 .collect();
185
186 Ok(profiles)
187}
188
189fn build_discovered_profile(entry: &std::fs::DirEntry) -> Option<DiscoveredProfile> {
190 let profile_yaml = ProfilePath::Config.resolve(&entry.path());
191 if !profile_yaml.exists() {
192 return None;
193 }
194
195 let name = entry.file_name().to_string_lossy().to_string();
196 let profile = ProfileLoader::load_from_path(&profile_yaml)
197 .map_err(|e| tracing::warn!(profile = %name, error = %e, "Skipping unreadable profile during discovery"))
198 .ok()?;
199
200 Some(DiscoveredProfile {
201 name,
202 path: profile_yaml,
203 profile,
204 })
205}
206
207pub fn generate_oauth_at_rest_pepper() -> String {
208 let mut rng = rng();
209 (0..64)
210 .map(|_| rng.sample(Alphanumeric))
211 .map(char::from)
212 .collect()
213}
214
215pub fn save_profile_yaml(profile: &Profile, path: &Path, header: Option<&str>) -> Result<()> {
216 if let Some(parent) = path.parent() {
217 std::fs::create_dir_all(parent)
218 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
219 }
220
221 let yaml = serde_yaml::to_string(profile).context("Failed to serialize profile")?;
222
223 let content = header.map_or_else(|| yaml.clone(), |h| format!("{}\n\n{}", h, yaml));
224
225 std::fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?;
226
227 Ok(())
228}