use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::OnceLock;
#[cfg(unix)]
use std::io::Write;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Workspace {
pub api_key: String,
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Config {
pub current: Option<String>,
#[serde(default)]
pub workspaces: HashMap<String, Workspace>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
}
fn config_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.context("Could not find config directory")?
.join("linear-cli");
fs::create_dir_all(&config_dir)?;
Ok(config_dir.join("config.toml"))
}
pub fn load_config() -> Result<Config> {
let path = config_path()?;
if path.exists() {
let content = fs::read_to_string(&path)?;
let mut config: Config = toml::from_str(&content)?;
if let Some(legacy_key) = config.api_key.take() {
if !config.workspaces.contains_key("default") {
config.workspaces.insert(
"default".to_string(),
Workspace {
api_key: legacy_key,
},
);
if config.current.is_none() {
config.current = Some("default".to_string());
}
save_config(&config)?;
}
}
Ok(config)
} else {
Ok(Config::default())
}
}
pub fn save_config(config: &Config) -> Result<()> {
let path = config_path()?;
let content = toml::to_string_pretty(config)?;
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)?;
file.write_all(content.as_bytes())?;
}
#[cfg(not(unix))]
{
fs::write(&path, content)?;
}
Ok(())
}
pub fn set_api_key(key: &str) -> Result<()> {
let mut config = load_config()?;
let profile = std::env::var("LINEAR_CLI_PROFILE")
.ok()
.filter(|p| !p.is_empty());
let workspace_name = profile
.or_else(|| config.current.clone())
.unwrap_or_else(|| "default".to_string());
config.workspaces.insert(
workspace_name.clone(),
Workspace {
api_key: key.to_string(),
},
);
if config.current.is_none() {
config.current = Some(workspace_name.clone());
}
save_config(&config)?;
Ok(())
}
pub fn get_api_key() -> Result<String> {
if let Ok(api_key) = std::env::var("LINEAR_API_KEY") {
if !api_key.is_empty() {
return Ok(api_key);
}
}
#[cfg(feature = "secure-storage")]
{
let config = load_config()?;
let profile = std::env::var("LINEAR_CLI_PROFILE")
.ok()
.filter(|p| !p.is_empty())
.or(config.current.clone())
.unwrap_or_else(|| "default".to_string());
if let Ok(Some(key)) = crate::keyring::get_key(&profile) {
return Ok(key);
}
}
let config = load_config()?;
let profile = std::env::var("LINEAR_CLI_PROFILE")
.ok()
.filter(|p| !p.is_empty());
let current = profile.or(config.current.clone()).context(
"No workspace selected. Run: linear config workspace-add <name> or set LINEAR_CLI_PROFILE",
)?;
let workspace = config.workspaces.get(¤t).context(format!(
"Workspace '{}' not found. Run: linear config workspace-add <name>",
current
))?;
Ok(workspace.api_key.clone())
}
pub fn config_file_path() -> Result<PathBuf> {
config_path()
}
pub fn current_profile() -> Result<String> {
static PROFILE: OnceLock<String> = OnceLock::new();
if let Some(cached) = PROFILE.get() {
return Ok(cached.clone());
}
let config = load_config()?;
let profile = std::env::var("LINEAR_CLI_PROFILE")
.ok()
.filter(|p| !p.is_empty());
let resolved = profile
.or(config.current)
.context("No workspace selected")?;
let _ = PROFILE.set(resolved.clone());
Ok(resolved)
}
pub fn set_workspace_key(name: &str, api_key: &str) -> Result<()> {
let mut config = load_config()?;
config.workspaces.insert(
name.to_string(),
Workspace {
api_key: api_key.to_string(),
},
);
if config.current.is_none() {
config.current = Some(name.to_string());
}
save_config(&config)?;
Ok(())
}
pub fn config_get(key: &str, raw: bool) -> Result<()> {
match key.to_lowercase().as_str() {
"api-key" | "api_key" => {
let api_key = get_api_key()?;
if raw || api_key.len() <= 12 {
println!("{}", api_key);
} else {
let masked = format!("{}...{}", &api_key[..8], &api_key[api_key.len() - 4..]);
println!("{}", masked);
}
}
"profile" => {
let profile = current_profile()?;
println!("{}", profile);
}
_ => anyhow::bail!("Unknown config key: {}", key),
}
Ok(())
}
pub fn config_set(key: &str, value: &str) -> Result<()> {
match key.to_lowercase().as_str() {
"api-key" | "api_key" => set_api_key(value),
"profile" => workspace_switch(value),
_ => anyhow::bail!("Unknown config key: {}", key),
}
}
pub fn show_config() -> Result<()> {
let config = load_config()?;
let path = config_path()?;
println!("Config file: {}", path.display());
println!();
if let Some(current) = &config.current {
println!("Current workspace: {}", current);
if let Some(workspace) = config.workspaces.get(current) {
let key = &workspace.api_key;
if key.len() > 12 {
let masked = format!("{}...{}", &key[..8], &key[key.len() - 4..]);
println!("API Key: {}", masked);
} else {
println!("API Key: {}", key);
}
}
} else {
println!("No workspace configured. Run: linear workspace add <name>");
}
Ok(())
}
pub fn workspace_add(name: &str, api_key: &str) -> Result<()> {
let mut config = load_config()?;
if config.workspaces.contains_key(name) {
anyhow::bail!(
"Workspace '{}' already exists. Use 'workspace remove' first to replace it.",
name
);
}
config.workspaces.insert(
name.to_string(),
Workspace {
api_key: api_key.to_string(),
},
);
if config.current.is_none() {
config.current = Some(name.to_string());
}
save_config(&config)?;
println!("Workspace '{}' added successfully!", name);
if config.current.as_ref() == Some(&name.to_string()) {
println!("Switched to workspace '{}'", name);
}
Ok(())
}
pub fn workspace_list() -> Result<()> {
let config = load_config()?;
if config.workspaces.is_empty() {
println!("No workspaces configured. Run: linear workspace add <name>");
return Ok(());
}
println!("Configured workspaces:");
println!();
for (name, workspace) in &config.workspaces {
let is_current = config.current.as_ref() == Some(name);
let marker = if is_current { "*" } else { " " };
let key = &workspace.api_key;
let masked = if key.len() > 12 {
format!("{}...{}", &key[..8], &key[key.len() - 4..])
} else {
key.clone()
};
println!("{} {} ({})", marker, name, masked);
}
println!();
println!("* = current workspace");
Ok(())
}
pub fn workspace_switch(name: &str) -> Result<()> {
let mut config = load_config()?;
if !config.workspaces.contains_key(name) {
anyhow::bail!(
"Workspace '{}' not found. Use 'workspace list' to see available workspaces.",
name
);
}
config.current = Some(name.to_string());
save_config(&config)?;
println!("Switched to workspace '{}'", name);
Ok(())
}
pub fn workspace_current() -> Result<()> {
let config = load_config()?;
if let Some(current) = &config.current {
println!("Current workspace: {}", current);
if let Some(workspace) = config.workspaces.get(current) {
let key = &workspace.api_key;
if key.len() > 12 {
let masked = format!("{}...{}", &key[..8], &key[key.len() - 4..]);
println!("API Key: {}", masked);
} else {
println!("API Key: {}", key);
}
}
} else {
println!("No workspace selected. Run: linear workspace add <name>");
}
Ok(())
}
pub fn workspace_remove(name: &str) -> Result<()> {
let mut config = load_config()?;
if !config.workspaces.contains_key(name) {
anyhow::bail!("Workspace '{}' not found.", name);
}
config.workspaces.remove(name);
if config.current.as_ref() == Some(&name.to_string()) {
config.current = config.workspaces.keys().next().cloned();
if let Some(new_current) = &config.current {
println!("Switched to workspace '{}'", new_current);
}
}
save_config(&config)?;
println!("Workspace '{}' removed.", name);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_default() {
let config = Config::default();
assert!(config.current.is_none());
assert!(config.workspaces.is_empty());
assert!(config.api_key.is_none());
}
#[test]
fn test_config_serialize_deserialize() {
let mut config = Config::default();
config.current = Some("prod".to_string());
config.workspaces.insert(
"prod".to_string(),
Workspace {
api_key: "lin_api_prod123".to_string(),
},
);
config.workspaces.insert(
"staging".to_string(),
Workspace {
api_key: "lin_api_staging456".to_string(),
},
);
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.current, Some("prod".to_string()));
assert_eq!(parsed.workspaces.len(), 2);
assert_eq!(parsed.workspaces["prod"].api_key, "lin_api_prod123");
assert_eq!(parsed.workspaces["staging"].api_key, "lin_api_staging456");
}
#[test]
fn test_config_legacy_migration_parse() {
let toml_str = r#"
api_key = "lin_api_legacy_key"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.api_key, Some("lin_api_legacy_key".to_string()));
assert!(config.workspaces.is_empty());
assert!(config.current.is_none());
}
#[test]
fn test_config_with_workspaces_parse() {
let toml_str = r#"
current = "default"
[workspaces.default]
api_key = "lin_api_key1"
[workspaces.staging]
api_key = "lin_api_key2"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.current, Some("default".to_string()));
assert_eq!(config.workspaces.len(), 2);
assert!(config.api_key.is_none());
}
#[test]
fn test_config_api_key_not_serialized_when_none() {
let config = Config {
current: Some("default".to_string()),
workspaces: HashMap::new(),
api_key: None,
};
let toml_str = toml::to_string_pretty(&config).unwrap();
assert!(!toml_str.contains("api_key"));
}
}