use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use anyhow::{Context, Result, bail};
use chrono::Utc;
use serde::{Deserialize, Serialize};
static ACTIVE_PROFILE: OnceLock<Option<String>> = OnceLock::new();
pub fn set_active_profile(name: Option<String>) -> Result<()> {
ACTIVE_PROFILE
.set(name)
.map_err(|_| anyhow::anyhow!("active profile already set"))
}
pub fn active_profile() -> Option<&'static str> {
ACTIVE_PROFILE.get().and_then(|opt| opt.as_deref())
}
pub fn resolve_profile_home() -> PathBuf {
let base = base_opencrabs_dir();
let profile_name = active_profile().map(String::from).or_else(|| {
std::env::var("OPENCRABS_PROFILE")
.ok()
.filter(|s| !s.is_empty())
});
match profile_name.as_deref() {
None | Some("default") => base,
Some(name) => base.join("profiles").join(name),
}
}
pub fn base_opencrabs_dir() -> PathBuf {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".opencrabs")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileEntry {
pub name: String,
pub description: Option<String>,
pub created_at: String,
pub last_used: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProfileRegistry {
#[serde(default)]
pub profiles: HashMap<String, ProfileEntry>,
}
impl ProfileRegistry {
fn path() -> PathBuf {
base_opencrabs_dir().join("profiles.toml")
}
pub fn load() -> Result<Self> {
let path = Self::path();
if !path.exists() {
return Ok(Self::default());
}
let contents = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
toml::from_str(&contents).with_context(|| format!("failed to parse {}", path.display()))
}
pub fn save(&self) -> Result<()> {
let path = Self::path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(self)?;
let tmp = path.with_extension("toml.tmp");
fs::write(&tmp, &contents).with_context(|| format!("failed to write {}", tmp.display()))?;
fs::rename(&tmp, &path)
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))
}
pub fn modify<F>(f: F) -> Result<Self>
where
F: FnOnce(&mut Self),
{
let path = Self::path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let lock_path = path.with_extension("toml.lock");
let lock_file = fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_path)
.with_context(|| format!("failed to open lock {}", lock_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let fd = lock_file.as_raw_fd();
let ret = unsafe { libc::flock(fd, libc::LOCK_EX) };
if ret != 0 {
bail!(
"failed to lock {}: {}",
lock_path.display(),
std::io::Error::last_os_error()
);
}
}
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
let _ = lock_file.as_raw_handle();
}
let mut registry = Self::load()?;
f(&mut registry);
registry.save()?;
let _ = lock_file;
Ok(registry)
}
pub fn register(&mut self, name: &str, description: Option<&str>) {
self.profiles.insert(
name.to_string(),
ProfileEntry {
name: name.to_string(),
description: description.map(String::from),
created_at: Utc::now().to_rfc3339(),
last_used: None,
},
);
}
pub fn touch(&mut self, name: &str) {
if let Some(entry) = self.profiles.get_mut(name) {
entry.last_used = Some(Utc::now().to_rfc3339());
}
}
}
pub fn create_profile(name: &str, description: Option<&str>) -> Result<PathBuf> {
validate_profile_name(name)?;
let profile_dir = base_opencrabs_dir().join("profiles").join(name);
if profile_dir.exists() {
bail!(
"profile '{}' already exists at {}",
name,
profile_dir.display()
);
}
fs::create_dir_all(&profile_dir)?;
fs::create_dir_all(profile_dir.join("memory"))?;
fs::create_dir_all(profile_dir.join("logs"))?;
seed_brain_templates(&profile_dir);
let name_owned = name.to_string();
let desc_owned = description.map(|s| s.to_string());
ProfileRegistry::modify(|reg| {
reg.register(&name_owned, desc_owned.as_deref());
})?;
tracing::info!("Created profile '{}' at {}", name, profile_dir.display());
Ok(profile_dir)
}
pub(crate) fn seed_brain_templates(profile_dir: &Path) {
const TEMPLATES: &[(&str, &str)] = &[
(
"SOUL.md",
include_str!("../docs/reference/templates/SOUL.md"),
),
(
"USER.md",
include_str!("../docs/reference/templates/USER.md"),
),
(
"AGENTS.md",
include_str!("../docs/reference/templates/AGENTS.md"),
),
(
"TOOLS.md",
include_str!("../docs/reference/templates/TOOLS.md"),
),
(
"MEMORY.md",
include_str!("../docs/reference/templates/MEMORY.md"),
),
(
"CODE.md",
include_str!("../docs/reference/templates/CODE.md"),
),
(
"SECURITY.md",
include_str!("../docs/reference/templates/SECURITY.md"),
),
];
for (filename, content) in TEMPLATES {
let target = profile_dir.join(filename);
if target.exists() {
continue;
}
if let Err(e) = fs::write(&target, content) {
tracing::warn!(
"create_profile: failed to seed {} in {}: {e}",
filename,
profile_dir.display(),
);
}
}
}
pub fn list_profiles() -> Result<Vec<ProfileEntry>> {
let registry = ProfileRegistry::load()?;
let mut profiles = vec![ProfileEntry {
name: "default".to_string(),
description: Some("Default profile (~/.opencrabs/)".to_string()),
created_at: String::new(),
last_used: None,
}];
let mut named: Vec<_> = registry.profiles.values().cloned().collect();
named.sort_by(|a, b| a.name.cmp(&b.name));
profiles.extend(named);
Ok(profiles)
}
pub fn delete_profile(name: &str) -> Result<()> {
if name == "default" {
bail!("cannot delete the default profile");
}
let profile_dir = base_opencrabs_dir().join("profiles").join(name);
if !profile_dir.exists() {
bail!("profile '{}' does not exist", name);
}
fs::remove_dir_all(&profile_dir).with_context(|| {
format!(
"failed to delete profile directory: {}",
profile_dir.display()
)
})?;
let name_owned = name.to_string();
ProfileRegistry::modify(|reg| {
reg.profiles.remove(&name_owned);
})?;
tracing::info!("Deleted profile '{}'", name);
Ok(())
}
pub fn export_profile(name: &str, output: &Path) -> Result<()> {
let profile_dir = if name == "default" {
base_opencrabs_dir()
} else {
let dir = base_opencrabs_dir().join("profiles").join(name);
if !dir.exists() {
bail!("profile '{}' does not exist", name);
}
dir
};
use flate2::Compression;
use flate2::write::GzEncoder;
use tar::Builder;
let file = fs::File::create(output)
.with_context(|| format!("failed to create {}", output.display()))?;
let enc = GzEncoder::new(file, Compression::default());
let mut tar = Builder::new(enc);
tar.append_dir_all(name, &profile_dir)
.with_context(|| "failed to add profile to archive")?;
tar.finish()?;
tracing::info!("Exported profile '{}' to {}", name, output.display());
Ok(())
}
pub fn import_profile(archive: &Path) -> Result<String> {
use flate2::read::GzDecoder;
use tar::Archive;
if !archive.exists() {
bail!("archive not found: {}", archive.display());
}
let file = fs::File::open(archive)?;
let dec = GzDecoder::new(file);
let mut ar = Archive::new(dec);
let profile_name = {
let file = fs::File::open(archive)?;
let dec = GzDecoder::new(file);
let mut ar = Archive::new(dec);
let first = ar.entries()?.next();
match first {
Some(Ok(entry)) => {
let path = entry.path()?;
path.components()
.next()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.unwrap_or_default()
}
_ => bail!("archive is empty"),
}
};
if profile_name.is_empty() {
bail!("could not determine profile name from archive");
}
let target = base_opencrabs_dir().join("profiles");
fs::create_dir_all(&target)?;
ar.unpack(&target)
.with_context(|| "failed to extract archive")?;
let pname = profile_name.clone();
ProfileRegistry::modify(|reg| {
if !reg.profiles.contains_key(&pname) {
reg.register(&pname, Some("Imported profile"));
}
})?;
tracing::info!(
"Imported profile '{}' from {}",
profile_name,
archive.display()
);
Ok(profile_name)
}
pub fn migrate_profile(from: &str, to: &str, force: bool) -> Result<Vec<String>> {
let base = base_opencrabs_dir();
let src_dir = if from == "default" {
base.clone()
} else {
let dir = base.join("profiles").join(from);
if !dir.exists() {
bail!("source profile '{}' does not exist", from);
}
dir
};
let dst_dir = if to == "default" {
base.clone()
} else {
let dir = base.join("profiles").join(to);
if !dir.exists() {
bail!(
"destination profile '{}' does not exist. Create it first with: opencrabs profile create {}",
to,
to
);
}
dir
};
if src_dir == dst_dir {
bail!("source and destination profiles are the same");
}
let mut migrated = Vec::new();
let skip_files = ["profiles.toml", "layout.json"];
for entry in fs::read_dir(&src_dir)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if path.is_file() {
let dominated = name_str.ends_with(".md") || name_str.ends_with(".toml");
if !dominated || skip_files.contains(&name_str.as_ref()) {
continue;
}
let dst_path = dst_dir.join(&name);
if dst_path.exists() && !force {
tracing::warn!(
"Skipping '{}' — already exists in '{}' (use --force to overwrite)",
name_str,
to
);
continue;
}
fs::copy(&path, &dst_path).with_context(|| {
format!(
"failed to copy {} to {}",
path.display(),
dst_path.display()
)
})?;
migrated.push(name_str.to_string());
}
}
let src_memory = src_dir.join("memory");
if src_memory.exists() && src_memory.is_dir() {
let dst_memory = dst_dir.join("memory");
fs::create_dir_all(&dst_memory)?;
for entry in fs::read_dir(&src_memory)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
let dst_path = dst_memory.join(&name);
if dst_path.exists() && !force {
tracing::warn!(
"Skipping memory/'{}' — already exists (use --force to overwrite)",
name_str
);
continue;
}
fs::copy(&path, &dst_path)?;
migrated.push(format!("memory/{}", name_str));
}
}
}
tracing::info!(
"Migrated {} files from profile '{}' to '{}'",
migrated.len(),
from,
to
);
Ok(migrated)
}
pub fn acquire_token_lock(channel: &str, token_hash: &str) -> Result<()> {
let lock_dir = base_opencrabs_dir().join("locks");
fs::create_dir_all(&lock_dir)?;
let lock_file = lock_dir.join(format!("{}_{}.lock", channel, token_hash));
let current_profile = active_profile().unwrap_or("default");
let pid = std::process::id();
if lock_file.exists() {
let contents = fs::read_to_string(&lock_file).unwrap_or_default();
let parts: Vec<&str> = contents.splitn(2, ':').collect();
if parts.len() == 2 {
let locked_profile = parts[0];
let locked_pid: u32 = parts[1].parse().unwrap_or(0);
if locked_profile == current_profile {
if is_pid_alive(locked_pid) && locked_pid != pid {
bail!(
"profile '{}' already running (PID {}). Only one instance per profile allowed.",
current_profile,
locked_pid
);
}
} else {
if is_pid_alive(locked_pid) {
bail!(
"channel '{}' token is locked by profile '{}' (PID {}). \
Two profiles cannot share the same bot credential.",
channel,
locked_profile,
locked_pid
);
}
}
}
}
fs::write(&lock_file, format!("{}:{}", current_profile, pid))?;
Ok(())
}
pub fn release_token_lock(channel: &str, token_hash: &str) {
let lock_file = base_opencrabs_dir()
.join("locks")
.join(format!("{}_{}.lock", channel, token_hash));
let _ = fs::remove_file(lock_file);
}
pub fn release_all_locks() {
let lock_dir = base_opencrabs_dir().join("locks");
let pid = std::process::id();
let current_profile = active_profile().unwrap_or("default");
let expected = format!("{}:{}", current_profile, pid);
if let Ok(entries) = fs::read_dir(&lock_dir) {
for entry in entries.flatten() {
if let Ok(contents) = fs::read_to_string(entry.path())
&& contents.trim() == expected
{
let _ = fs::remove_file(entry.path());
}
}
}
}
pub fn hash_token(token: &str) -> String {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut hasher = DefaultHasher::new();
token.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
pub fn validate_profile_name(name: &str) -> Result<()> {
if name == "default" {
bail!("'default' is reserved — the default profile is ~/.opencrabs/");
}
if name.is_empty() || name.len() > 64 {
bail!("profile name must be 1-64 characters");
}
if !name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
bail!("profile name can only contain alphanumeric, hyphens, and underscores");
}
Ok(())
}
fn is_pid_alive(pid: u32) -> bool {
#[cfg(unix)]
{
let ret = unsafe { libc::kill(pid as i32, 0) };
if ret == 0 {
return true;
}
std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
}
#[cfg(windows)]
{
unsafe extern "system" {
fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> isize;
fn CloseHandle(hObject: isize) -> i32;
}
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle == 0 {
false
} else {
unsafe { CloseHandle(handle) };
true
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
false
}
}