1use std::io::Read as _;
6use std::path::Path;
7
8use anyhow::{Context, Result, anyhow, bail};
9
10use super::adapters::KeyStore;
11
12const MAX_IMPORT_BYTES: u64 = 1024 * 1024;
13
14enum Auth {
15 ApiKey,
17 Session,
19 None,
20}
21
22pub fn import_codex(source: &Path, destination: &Path) -> Result<Vec<String>> {
26 let source = std::fs::canonicalize(source)
27 .with_context(|| format!("resolve Codex home {}", source.display()))?;
28 let destination = std::fs::canonicalize(destination)
29 .with_context(|| format!("resolve SCV Codex home {}", destination.display()))?;
30 if source == destination {
31 bail!(
32 "{} is already SCV's Codex agent home; pass your own Codex home with --from",
33 source.display()
34 );
35 }
36 let config = read_bounded(&source.join("config.toml"))?;
37 let auth = read_bounded(&source.join("auth.json"))?;
38 if config.is_none() && auth.is_none() {
39 bail!("no config.toml or auth.json in {}", source.display());
40 }
41 let table = config
42 .as_deref()
43 .map(str::parse::<toml::Table>)
44 .transpose()
45 .context("parse Codex config.toml")?;
46 let auth_kind = auth.as_deref().map(classify_auth).transpose()?;
47
48 let mut notes = Vec::new();
49 if let (Some(text), Some(table)) = (&config, &table) {
50 write_private(&destination.join("config.toml"), text)?;
51 notes.push(format!("Copied config.toml{}", describe(table)));
52 notes.extend(config_notes(table));
53 }
54 match (&auth, auth_kind) {
55 (Some(text), Some(Auth::ApiKey)) => {
56 write_private(&destination.join("auth.json"), text)?;
57 notes.push("Copied the API-key sign-in from auth.json".into());
58 }
59 (Some(_), Some(Auth::Session)) => notes.push(
60 "Skipped auth.json: a ChatGPT sign-in's refresh token must not be shared; \
61 sign SCV in separately with `scv agents login codex`"
62 .into(),
63 ),
64 (Some(_), Some(Auth::None)) => notes.push("Skipped auth.json: it holds no API key".into()),
65 _ => {}
66 }
67 Ok(notes)
68}
69
70pub fn codex_copied_files(source: &Path) -> Vec<String> {
73 let mut files = Vec::new();
74 if source.join("config.toml").is_file() {
75 files.push("config.toml".to_owned());
76 }
77 if let Ok(Some(text)) = read_bounded(&source.join("auth.json"))
78 && matches!(classify_auth(&text), Ok(Auth::ApiKey))
79 {
80 files.push("auth.json".to_owned());
81 }
82 files
83}
84
85fn read_bounded(path: &Path) -> Result<Option<String>> {
86 let file = match std::fs::File::open(path) {
87 Ok(file) => file,
88 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
89 Err(error) => return Err(anyhow!(error).context(format!("open {}", path.display()))),
90 };
91 if !file.metadata()?.is_file() {
92 bail!("{} is not a regular file", path.display());
93 }
94 let mut text = String::new();
95 file.take(MAX_IMPORT_BYTES + 1)
96 .read_to_string(&mut text)
97 .with_context(|| format!("read {}", path.display()))?;
98 if text.len() as u64 > MAX_IMPORT_BYTES {
99 bail!("{} exceeds 1 MiB", path.display());
100 }
101 Ok(Some(text))
102}
103
104fn classify_auth(text: &str) -> Result<Auth> {
105 let value: serde_json::Value =
106 serde_json::from_str(text).map_err(|_| anyhow!("Codex auth.json is not valid JSON"))?;
107 let object = value
108 .as_object()
109 .ok_or_else(|| anyhow!("Codex auth.json is not a JSON object"))?;
110 if object.get("tokens").is_some_and(|tokens| !tokens.is_null()) {
111 return Ok(Auth::Session);
112 }
113 let has_key = object
114 .get("OPENAI_API_KEY")
115 .and_then(serde_json::Value::as_str)
116 .is_some_and(|key| !key.trim().is_empty());
117 Ok(if has_key { Auth::ApiKey } else { Auth::None })
118}
119
120fn describe(table: &toml::Table) -> String {
122 let fields: Vec<String> = ["model_provider", "model"]
123 .into_iter()
124 .filter_map(|key| Some(format!("{key} {:?}", table.get(key)?.as_str()?)))
125 .collect();
126 if fields.is_empty() {
127 String::new()
128 } else {
129 format!(" ({})", fields.join(", "))
130 }
131}
132
133fn config_notes(table: &toml::Table) -> Vec<String> {
134 let mut notes = Vec::new();
135 let policy: Vec<String> = ["sandbox_mode", "approval_policy"]
136 .into_iter()
137 .filter_map(|key| Some(format!("{key} {:?}", table.get(key)?.as_str()?)))
138 .collect();
139 if !policy.is_empty() {
140 notes.push(format!(
141 "Delegated Codex runs also use {}",
142 policy.join(" and ")
143 ));
144 }
145 let providers = table
146 .get("model_providers")
147 .and_then(toml::Value::as_table)
148 .into_iter()
149 .flatten();
150 for (name, provider) in providers {
151 let Some(variable) = provider.get("env_key").and_then(toml::Value::as_str) else {
152 continue;
153 };
154 notes.push(
155 if super::adapters::is_removed_agent_variable(std::ffi::OsStr::new(variable)) {
156 format!(
157 "Warning: provider {name:?} reads its key from ${variable}, which SCV \
158 removes from delegated agents; keep the key in auth.json \
159 (requires_openai_auth) or experimental_bearer_token instead"
160 )
161 } else {
162 format!(
163 "Note: provider {name:?} reads its key from ${variable}; the SCV daemon's \
164 environment must provide it (the user service does not load your shell profile)"
165 )
166 },
167 );
168 }
169 notes
170}
171
172pub fn import_grok(source: &Path, destination: &Path) -> Result<Vec<String>> {
179 let source = std::fs::canonicalize(source)
180 .with_context(|| format!("resolve Grok home {}", source.display()))?;
181 std::fs::create_dir_all(destination)
182 .with_context(|| format!("create {}", destination.display()))?;
183 let destination = std::fs::canonicalize(destination)
184 .with_context(|| format!("resolve SCV Grok home {}", destination.display()))?;
185 if source == destination {
186 bail!(
187 "{} is already SCV's Grok home; pass your own Grok home with --from",
188 source.display()
189 );
190 }
191 let text = read_bounded(&source.join("config.toml"))?
192 .ok_or_else(|| anyhow!("no config.toml in {}", source.display()))?;
193 let user: toml::Table = text
195 .parse()
196 .map_err(|_| anyhow!("your Grok config.toml is not valid TOML"))?;
197 let target = destination.join("config.toml");
198 let existing: toml::Table = match read_bounded(&target)? {
199 Some(existing) => existing.parse().map_err(|_| {
200 anyhow!(
201 "SCV's Grok config.toml ({}) is not valid TOML; move it aside and import again",
202 target.display()
203 )
204 })?,
205 None => toml::Table::new(),
206 };
207 let kept: toml::Table = existing
208 .into_iter()
209 .filter(|(key, _)| !user.contains_key(key))
210 .collect();
211 let merged = if kept.values().all(toml::Value::is_table) {
212 let mut merged = text.trim_end().to_owned();
214 merged.push('\n');
215 if !kept.is_empty() {
216 merged.push('\n');
217 merged.push_str(&toml::to_string(&kept).context("serialize kept settings")?);
218 }
219 merged
220 } else {
221 let mut table = user.clone();
224 table.extend(kept.clone());
225 toml::to_string(&table).context("serialize merged Grok config")?
226 };
227 if merged.parse::<toml::Table>().is_err() {
228 bail!("the merged Grok config.toml would not be valid TOML; nothing was written");
229 }
230 write_private(&target, &merged)?;
231
232 let mut notes = vec![format!("Copied config.toml{}", describe_grok(&user))];
233 if !kept.is_empty() {
234 let names: Vec<String> = kept.keys().map(|key| format!("{key:?}")).collect();
235 notes.push(format!("Kept SCV-only settings: {}", names.join(", ")));
236 }
237 match grok_default_key(&user) {
238 GrokKey::InConfig(_) | GrokKey::NoDefault => {}
239 GrokKey::FromVariable(model, variable) => notes.push(grok_variable_note(&model, &variable)),
240 GrokKey::Missing(model) => notes.push(format!(
241 "Note: default model {model:?} has no api_key in config; sign SCV in with \
242 `scv agents login grok` or add api_key to its profile"
243 )),
244 }
245 if source.join("auth.json").exists() {
246 notes.push(
247 "Skipped auth.json: `grok login` sign-ins are not shared; sign SCV in \
248 separately with `scv agents login grok` if you need one"
249 .into(),
250 );
251 }
252 Ok(notes)
253}
254
255fn describe_grok(table: &toml::Table) -> String {
257 let profiles: Vec<String> = table
258 .get("model")
259 .and_then(toml::Value::as_table)
260 .map(|models| models.keys().map(|key| format!("{key:?}")).collect())
261 .unwrap_or_default();
262 let default =
263 grok_default(table).map_or_else(|| "built-in".into(), |model| format!("{model:?}"));
264 if profiles.is_empty() {
265 format!(" (default model {default}; no model profiles)")
266 } else {
267 format!(
268 " (default model {default}; profiles {})",
269 profiles.join(", ")
270 )
271 }
272}
273
274enum GrokKey {
276 NoDefault,
278 InConfig(String),
280 FromVariable(String, Vec<String>),
282 Missing(String),
284}
285
286fn grok_default(table: &toml::Table) -> Option<String> {
287 table
288 .get("models")?
289 .get("default")?
290 .as_str()
291 .map(ToOwned::to_owned)
292}
293
294fn grok_default_key(table: &toml::Table) -> GrokKey {
297 let Some(default) = grok_default(table) else {
298 return GrokKey::NoDefault;
299 };
300 let models = table.get("model").and_then(toml::Value::as_table);
301 let profile = models.and_then(|models| {
302 models.get(&default).or_else(|| {
303 models.values().find(|profile| {
304 profile.get("model").and_then(toml::Value::as_str) == Some(&default)
305 })
306 })
307 });
308 let Some(profile) = profile else {
309 return GrokKey::Missing(default);
310 };
311 if profile
312 .get("api_key")
313 .and_then(toml::Value::as_str)
314 .is_some_and(|key| !key.trim().is_empty())
315 {
316 return GrokKey::InConfig(default);
317 }
318 let variables: Vec<String> = match profile.get("env_key") {
319 Some(toml::Value::String(name)) => vec![name.clone()],
320 Some(toml::Value::Array(names)) => names
321 .iter()
322 .filter_map(toml::Value::as_str)
323 .map(ToOwned::to_owned)
324 .collect(),
325 _ => Vec::new(),
326 };
327 if variables.is_empty() {
328 GrokKey::Missing(default)
329 } else {
330 GrokKey::FromVariable(default, variables)
331 }
332}
333
334fn usable_grok_variable(variables: &[String]) -> Option<&String> {
337 variables.iter().find(|variable| {
338 !super::adapters::is_removed_agent_variable(std::ffi::OsStr::new(variable.as_str()))
339 && std::env::var_os(variable).is_some_and(|value| !value.is_empty())
340 })
341}
342
343fn grok_variable_note(model: &str, variables: &[String]) -> String {
344 let names: Vec<String> = variables.iter().map(|name| format!("${name}")).collect();
345 format!(
346 "Note: default model {model:?} reads its key from {}; SCV removes key variables \
347 from delegated agents and its service does not load your shell profile, so put \
348 api_key in the profile instead",
349 names.join(" or ")
350 )
351}
352
353#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct StoredStatus {
357 pub ready: bool,
358 pub lines: Vec<String>,
359}
360
361impl StoredStatus {
362 fn ready(line: String) -> Self {
363 Self {
364 ready: true,
365 lines: vec![line],
366 }
367 }
368
369 fn missing(lines: Vec<String>) -> Self {
370 Self {
371 ready: false,
372 lines,
373 }
374 }
375}
376
377fn grok_status(auth: &Path, config: &Path, home: &Path) -> Result<StoredStatus> {
378 let entries = read_json_object(auth)?.map_or(0, |object| {
379 object.values().filter(|value| !value.is_null()).count()
380 });
381 if entries > 0 {
382 return Ok(StoredStatus::ready(format!(
383 "signed in ({})",
384 display(auth, home)
385 )));
386 }
387 let Some(text) = read_bounded(config)? else {
388 return Ok(StoredStatus::missing(vec!["not signed in".into()]));
389 };
390 let table: toml::Table = text
391 .parse()
392 .map_err(|_| anyhow!("{} is not valid TOML", display(config, home)))?;
393 Ok(match grok_default_key(&table) {
394 GrokKey::InConfig(model) => {
395 StoredStatus::ready(format!("signed in (API key in config, model {model:?})"))
396 }
397 GrokKey::FromVariable(model, variables) => match usable_grok_variable(&variables) {
398 Some(variable) => {
399 StoredStatus::ready(format!("signed in (key from ${variable}, model {model:?})"))
400 }
401 None => StoredStatus::missing(vec![
402 "not signed in".into(),
403 grok_variable_note(&model, &variables),
404 ]),
405 },
406 GrokKey::Missing(model) => StoredStatus::missing(vec![
407 "not signed in".into(),
408 format!("default model {model:?} has no api_key in config"),
409 ]),
410 GrokKey::NoDefault => StoredStatus::missing(vec!["not signed in".into()]),
411 })
412}
413
414fn write_private(path: &Path, contents: &str) -> Result<()> {
415 scv_client::fs::replace_private(path, contents.as_bytes())
416 .with_context(|| format!("replace {}", path.display()))
417}
418
419pub const MAX_FIELD_BYTES: usize = 4096;
421
422pub(crate) const PI_PROVIDER: &str = "scv";
424
425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
427pub enum WireApi {
428 Responses,
429 ChatCompletions,
430}
431
432impl WireApi {
433 fn pi_api(self) -> &'static str {
434 match self {
435 Self::Responses => "openai-responses",
436 Self::ChatCompletions => "openai-completions",
437 }
438 }
439}
440
441#[derive(Debug, Clone)]
443pub struct Endpoint {
444 pub base_url: String,
445 pub api: WireApi,
446 pub model: String,
447}
448
449pub fn validate_secret(secret: &str) -> Result<()> {
452 if secret.is_empty() {
453 bail!("no key entered");
454 }
455 if secret.len() > MAX_FIELD_BYTES
456 || secret
457 .chars()
458 .any(|c| c.is_whitespace() || c.is_control() || c == '"' || c == '\\')
459 {
460 bail!("the key must be one line of at most {MAX_FIELD_BYTES} printable characters");
461 }
462 Ok(())
463}
464
465pub fn store_key(store: KeyStore, home: &Path, key: &str) -> Result<Vec<String>> {
467 validate_secret(key)?;
468 match store {
469 KeyStore::DshRefs { path, variable } => {
470 let path = home.join(path);
471 create_private_dirs(home, &path)?;
472 let quoted = serde_json::to_string(key)?;
474 write_private(
475 &path,
476 &format!("version: 1\n\nrefs:\n {variable}: {quoted}\n"),
477 )?;
478 Ok(vec![format!(
479 "Stored the API key as {variable} in {}",
480 display(&path, home)
481 )])
482 }
483 KeyStore::Grok { .. } | KeyStore::Pi { .. } => {
484 bail!("this agent signs in with its own login, not a stored key")
485 }
486 KeyStore::Scv { .. } => {
487 bail!("the nested SCV uses SCV's own provider: run `scv agents import scv`")
488 }
489 }
490}
491
492pub fn stored_status(store: KeyStore, home: &Path) -> Result<StoredStatus> {
494 match store {
495 KeyStore::Grok { auth, config } => grok_status(&home.join(auth), &home.join(config), home),
496 KeyStore::DshRefs { path, variable } => {
497 let path = home.join(path);
498 let stored = read_bounded(&path)?.is_some_and(|text| {
499 text.lines().any(|line| {
500 line.trim_start()
501 .strip_prefix(variable)
502 .and_then(|rest| rest.strip_prefix(':'))
503 .is_some_and(|value| !matches!(value.trim(), "" | "\"\"" | "''"))
504 })
505 });
506 Ok(if stored {
507 StoredStatus::ready(format!("API key stored as {variable}"))
508 } else {
509 StoredStatus::missing(vec!["not signed in".into()])
510 })
511 }
512 KeyStore::Pi { dir } => pi_status(&home.join(dir)),
513 KeyStore::Scv { config } => scv_child_status(&home.join(config)),
514 }
515}
516
517pub fn remove_stored(store: KeyStore, home: &Path) -> Result<Vec<String>> {
519 match store {
520 KeyStore::Grok { auth: path, .. }
521 | KeyStore::DshRefs { path, .. }
522 | KeyStore::Scv { config: path } => {
523 let path = home.join(path);
524 Ok(vec![if remove_if_present(&path)? {
525 format!("Removed {}", display(&path, home))
526 } else {
527 "Nothing stored".into()
528 }])
529 }
530 KeyStore::Pi { dir } => {
531 let dir = home.join(dir);
532 let mut notes = Vec::new();
533 if remove_if_present(&dir.join("auth.json"))? {
534 notes.push("Removed pi's stored sign-ins (auth.json)".into());
535 }
536 let models = dir.join("models.json");
537 if let Some(mut object) = read_json_object(&models)?
538 && let Some(providers) = object
539 .get_mut("providers")
540 .and_then(serde_json::Value::as_object_mut)
541 && providers.remove(PI_PROVIDER).is_some()
542 {
543 write_json(&models, &object)?;
544 notes.push(format!(
545 "Removed the {PI_PROVIDER} endpoint from models.json"
546 ));
547 }
548 let settings = dir.join("settings.json");
549 if let Some(mut object) = read_json_object(&settings)?
550 && object
551 .get("defaultProvider")
552 .and_then(serde_json::Value::as_str)
553 == Some(PI_PROVIDER)
554 {
555 object.remove("defaultProvider");
556 object.remove("defaultModel");
557 write_json(&settings, &object)?;
558 notes.push("Cleared pi's default model".into());
559 }
560 if notes.is_empty() {
561 notes.push("Nothing stored".into());
562 }
563 Ok(notes)
564 }
565 }
566}
567
568pub(crate) const SCV_CHILD_PROVIDER: &str = "scv";
570
571pub struct ScvChildProvider<'a> {
573 pub wire_api: &'a str,
574 pub model: &'a str,
575 pub base_url: &'a str,
576 pub timeout_seconds: u64,
577 pub headers: &'a std::collections::HashMap<String, scv_client::Secret>,
578 pub hosted_web_search: bool,
580}
581
582pub fn configure_scv_child(
588 home: &Path,
589 provider: &ScvChildProvider<'_>,
590 key: &str,
591) -> Result<Vec<String>> {
592 validate_secret(key)?;
593 let base_url = validate_base_url(provider.base_url)?;
594 if !valid_model_id(provider.model) {
595 bail!("invalid model id {:?}", provider.model);
596 }
597 let path = home.join("config.toml");
598 create_private_dirs(home, &path)?;
599 let mut table: toml::Table = match read_bounded(&path)? {
600 Some(text) => text
601 .parse()
602 .with_context(|| format!("parse existing {}", display(&path, home)))?,
603 None => toml::Table::new(),
604 };
605 let mut selection = toml::Table::new();
606 selection.insert("active".into(), SCV_CHILD_PROVIDER.into());
607 table.insert("provider".into(), selection.into());
608 let mut profile = toml::Table::new();
609 profile.insert("kind".into(), "openai-compatible".into());
610 profile.insert("wire_api".into(), provider.wire_api.into());
611 profile.insert("model".into(), provider.model.into());
612 profile.insert("base_url".into(), base_url.clone().into());
613 profile.insert("api_key".into(), key.into());
614 profile.insert(
615 "timeout_seconds".into(),
616 i64::try_from(provider.timeout_seconds)
617 .unwrap_or(i64::MAX)
618 .into(),
619 );
620 if !provider.headers.is_empty() {
621 let headers: toml::Table = provider
622 .headers
623 .iter()
624 .map(|(name, value)| (name.clone(), value.expose().into()))
625 .collect();
626 profile.insert("headers".into(), headers.into());
627 }
628 let providers = table
629 .entry("providers")
630 .or_insert_with(|| toml::Table::new().into());
631 let Some(providers) = providers.as_table_mut() else {
632 bail!(
633 "{} has a `providers` value that is not a table",
634 display(&path, home)
635 );
636 };
637 providers.insert(SCV_CHILD_PROVIDER.into(), profile.into());
638 if provider.hosted_web_search {
639 let web = table
640 .entry("web")
641 .or_insert_with(|| toml::Table::new().into());
642 let Some(web) = web.as_table_mut() else {
643 bail!(
644 "{} has a `web` value that is not a table",
645 display(&path, home)
646 );
647 };
648 web.insert("search".into(), "provider".into());
649 }
650 let text = toml::to_string(&table).context("encode the nested SCV config")?;
651 text.parse::<toml::Table>()
652 .context("the nested SCV config did not round-trip")?;
653 write_private(&path, &text)?;
654 let mut lines = vec![
655 format!(
656 "Wrote {} (mode 0600): provider {SCV_CHILD_PROVIDER:?}, model {:?} at {}",
657 display(&path, home),
658 provider.model,
659 host(&base_url)
660 ),
661 "The API key is stored in that file and not shown".into(),
662 ];
663 if provider.hosted_web_search {
664 lines.push("Hosted web search is on, as in SCV's own config".into());
665 }
666 Ok(lines)
667}
668
669fn scv_child_status(path: &Path) -> Result<StoredStatus> {
671 let Some(text) = read_bounded(path)? else {
672 return Ok(StoredStatus::missing(vec![
673 "not configured: run `scv agents import scv`".into(),
674 ]));
675 };
676 let Ok(table) = text.parse::<toml::Table>() else {
677 return Ok(StoredStatus::missing(vec![
678 "config.toml is not valid TOML".into(),
679 ]));
680 };
681 let active = table
682 .get("provider")
683 .and_then(|provider| provider.get("active"))
684 .and_then(toml::Value::as_str);
685 let profile = active.and_then(|active| {
686 table
687 .get("providers")
688 .and_then(|providers| providers.get(active))
689 .and_then(toml::Value::as_table)
690 });
691 let (Some(active), Some(profile)) = (active, profile) else {
692 return Ok(StoredStatus::missing(vec![
693 "no active provider: run `scv agents import scv`".into(),
694 ]));
695 };
696 let text = |key: &str| profile.get(key).and_then(toml::Value::as_str);
697 let keyed = text("api_key").is_some_and(|key| !key.trim().is_empty());
698 let location = format!(
699 "provider {active:?}: model {:?} at {}",
700 text("model").unwrap_or("unset"),
701 text("base_url").map_or_else(|| "no base URL".into(), host)
702 );
703 Ok(if keyed {
704 StoredStatus::ready(format!("{location}, API key stored"))
705 } else {
706 StoredStatus::missing(vec![format!(
707 "{location}, no stored API key (a key variable is not inherited): run `scv agents import scv`"
708 )])
709 })
710}
711
712pub fn configure_pi_endpoint(dir: &Path, endpoint: &Endpoint, key: &str) -> Result<Vec<String>> {
716 validate_secret(key)?;
717 let base_url = validate_base_url(&endpoint.base_url)?;
718 if !valid_model_id(&endpoint.model) {
719 bail!("invalid model id {:?}", endpoint.model);
720 }
721 create_private_dirs(dir, &dir.join("auth.json"))?;
722 let mut models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
724 let mut auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
725 let mut settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
726
727 let providers = models
728 .entry("providers")
729 .or_insert_with(|| serde_json::json!({}));
730 let providers = providers
731 .as_object_mut()
732 .ok_or_else(|| anyhow!("pi models.json has a non-object \"providers\""))?;
733 let mut provider = serde_json::json!({
734 "baseUrl": base_url,
735 "api": endpoint.api.pi_api(),
736 "models": [{"id": endpoint.model}],
737 });
738 if endpoint.api == WireApi::Responses {
739 provider["compat"] = serde_json::json!({"sessionAffinityFormat": "openai-nosession"});
743 }
744 providers.insert(PI_PROVIDER.into(), provider);
745 auth.insert(
746 PI_PROVIDER.into(),
747 serde_json::json!({"type": "api_key", "key": key}),
748 );
749 settings.insert("defaultProvider".into(), PI_PROVIDER.into());
750 settings.insert("defaultModel".into(), endpoint.model.clone().into());
751
752 write_json(&dir.join("models.json"), &models)?;
753 write_json(&dir.join("auth.json"), &auth)?;
754 write_json(&dir.join("settings.json"), &settings)?;
755 Ok(vec![
756 format!(
757 "Configured pi provider {PI_PROVIDER:?}: {} at {}, model {:?}",
758 endpoint.api.pi_api(),
759 host(&base_url),
760 endpoint.model
761 ),
762 "Stored its API key in pi's auth.json (mode 0600) and made it pi's default".into(),
763 ])
764}
765
766fn pi_status(dir: &Path) -> Result<StoredStatus> {
767 let settings = read_json_object(&dir.join("settings.json"))?.unwrap_or_default();
768 let auth = read_json_object(&dir.join("auth.json"))?.unwrap_or_default();
769 let models = read_json_object(&dir.join("models.json"))?.unwrap_or_default();
770 let mut lines = Vec::new();
771 let text = |object: &serde_json::Map<String, serde_json::Value>, key: &str| {
772 object
773 .get(key)
774 .and_then(serde_json::Value::as_str)
775 .map(ToOwned::to_owned)
776 };
777 let provider = text(&settings, "defaultProvider");
778 if let Some(provider) = &provider {
779 let endpoint = models
780 .get("providers")
781 .and_then(|providers| providers.get(provider))
782 .and_then(serde_json::Value::as_object);
783 let location = endpoint
784 .map(|endpoint| {
785 format!(
786 " ({} at {})",
787 text(endpoint, "api").unwrap_or_else(|| "api unset".into()),
788 text(endpoint, "baseUrl")
789 .map_or_else(|| "no base URL".into(), |url| host(&url))
790 )
791 })
792 .unwrap_or_default();
793 lines.push(format!(
794 "default provider {provider:?}{location}, model {:?}",
795 text(&settings, "defaultModel").unwrap_or_else(|| "unset".into())
796 ));
797 }
798 let mut signed_in: Vec<&String> = auth.keys().collect();
799 signed_in.sort();
800 let ready = !signed_in.is_empty();
801 if ready {
802 let names: Vec<String> = signed_in.iter().map(|name| format!("{name:?}")).collect();
803 lines.push(format!("stored sign-ins: {}", names.join(", ")));
804 } else {
805 lines.push("not signed in".into());
806 }
807 Ok(StoredStatus { ready, lines })
808}
809
810fn validate_base_url(value: &str) -> Result<String> {
811 let value = value.trim().trim_end_matches('/');
812 let rest = value
813 .strip_prefix("https://")
814 .or_else(|| value.strip_prefix("http://"))
815 .ok_or_else(|| anyhow!("the base URL must start with https:// or http://"))?;
816 let authority = rest.split('/').next().unwrap_or_default();
817 if authority.is_empty()
818 || authority.contains('@')
819 || value.len() > MAX_FIELD_BYTES
820 || value
821 .chars()
822 .any(|c| c.is_whitespace() || c.is_control() || matches!(c, '?' | '#'))
823 {
824 bail!("the base URL must be a plain http(s) URL without credentials, query, or fragment");
825 }
826 Ok(value.to_owned())
827}
828
829fn host(url: &str) -> String {
831 url.split("://")
832 .nth(1)
833 .and_then(|rest| rest.split('/').next())
834 .unwrap_or(url)
835 .to_owned()
836}
837
838fn valid_model_id(model: &str) -> bool {
839 !model.is_empty()
840 && model.len() <= 128
841 && !model.starts_with(['-', '@'])
842 && model
843 .chars()
844 .all(|c| c.is_ascii_alphanumeric() || "._:/@[]-".contains(c))
845}
846
847fn display(path: &Path, home: &Path) -> String {
848 path.strip_prefix(home).map_or_else(
849 |_| path.display().to_string(),
850 |relative| relative.display().to_string(),
851 )
852}
853
854fn read_json_object(path: &Path) -> Result<Option<serde_json::Map<String, serde_json::Value>>> {
855 let Some(text) = read_bounded(path)? else {
856 return Ok(None);
857 };
858 if text.trim().is_empty() {
859 return Ok(Some(serde_json::Map::new()));
860 }
861 match serde_json::from_str(&text) {
862 Ok(serde_json::Value::Object(object)) => Ok(Some(object)),
863 _ => bail!("{} is not a JSON object", path.display()),
865 }
866}
867
868fn write_json(path: &Path, object: &serde_json::Map<String, serde_json::Value>) -> Result<()> {
869 write_private(
870 path,
871 &format!("{}\n", serde_json::to_string_pretty(object)?),
872 )
873}
874
875fn remove_if_present(path: &Path) -> Result<bool> {
876 match std::fs::remove_file(path) {
877 Ok(()) => Ok(true),
878 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
879 Err(error) => Err(anyhow!(error).context(format!("remove {}", path.display()))),
880 }
881}
882
883fn create_private_dirs(root: &Path, file: &Path) -> Result<()> {
885 let parent = file
886 .parent()
887 .ok_or_else(|| anyhow!("{} has no parent", file.display()))?;
888 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
889 use std::os::unix::fs::PermissionsExt as _;
890 let mut dir = parent;
891 loop {
892 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
893 match dir.parent() {
894 Some(next) if next.starts_with(root) && next != root => dir = next,
895 _ => break,
896 }
897 }
898 Ok(())
899}
900
901#[cfg(test)]
902mod tests;