From a705d0584ba129055c43e330df0bec676aaae434 Mon Sep 17 00:00:00 2001 From: Cadu Date: Sun, 8 Mar 2026 15:50:19 -0300 Subject: [PATCH 1/7] feat: multi-profile auth via `--profile` flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `--profile ` global flag to switch between multiple API tokens stored as `~/.linear_api_token_` files. Profiles are just named token files — no config format, no new dependencies. SDK breaking changes: - Remove `Client::auto()`, `Client::from_file()`, `auth::auto_token()` - Add `Client::from_token_file(&Path)`, change `auth::token_from_file()` to accept `&Path` — SDK is now path-agnostic, CLI owns conventions CLI: - `--profile` conflicts with `--api-token` (clap hard error) - `--profile default` maps to `~/.linear_api_token` - `--profile` skips env var — goes straight to the named file - Actionable error messages when profile file is missing - `lineark usage` shows active profile and available alternatives Closes #126 --- crates/lineark-sdk/README.md | 7 +- crates/lineark-sdk/src/auth.rs | 78 ++++---- crates/lineark-sdk/src/client.rs | 18 +- crates/lineark-sdk/src/helpers.rs | 4 +- crates/lineark-test-utils/src/token.rs | 6 +- crates/lineark/src/commands/usage.rs | 64 +++++- crates/lineark/src/main.rs | 85 +++++++- crates/lineark/tests/offline.rs | 261 +++++++++++++++++++++++++ 8 files changed, 451 insertions(+), 72 deletions(-) diff --git a/crates/lineark-sdk/README.md b/crates/lineark-sdk/README.md index d92e0b1..62aa0d2 100644 --- a/crates/lineark-sdk/README.md +++ b/crates/lineark-sdk/README.md @@ -18,7 +18,7 @@ use lineark_sdk::generated::types::{User, Team}; #[tokio::main] async fn main() -> Result<(), Box> { - let client = Client::auto()?; + let client = Client::from_env()?; let me = client.whoami::().await?; println!("Logged in as: {}", me.name.as_deref().unwrap_or("?")); @@ -43,8 +43,7 @@ Create a [Linear API token](https://linear.app/settings/account/security) and pr |--------|---------| | Direct | `Client::from_token("lin_api_...")` | | Env var | `export LINEAR_API_TOKEN="lin_api_..."` then `Client::from_env()` | -| File | `echo "lin_api_..." > ~/.linear_api_token` then `Client::from_file()` | -| Auto | `Client::auto()` — tries env var, then file | +| File | `Client::from_token_file(Path::new("/path/to/token"))` | ## Queries @@ -113,7 +112,7 @@ struct LeanIssue { title: Option, } -let client = Client::auto()?; +let client = Client::from_env()?; let issues = client.issues::().first(10).send().await?; for issue in &issues.nodes { println!("{}", issue.title.as_deref().unwrap_or("?")); diff --git a/crates/lineark-sdk/src/auth.rs b/crates/lineark-sdk/src/auth.rs index 65783ee..082bb38 100644 --- a/crates/lineark-sdk/src/auth.rs +++ b/crates/lineark-sdk/src/auth.rs @@ -1,24 +1,28 @@ //! API token resolution. //! //! Supports three sources (in precedence order): explicit token, the -//! `LINEAR_API_TOKEN` environment variable, and `~/.linear_api_token` file. +//! `LINEAR_API_TOKEN` environment variable, and a token file at any path. use crate::error::LinearError; -use std::path::PathBuf; +use std::path::Path; -/// Resolve a Linear API token from the filesystem. -/// Reads `~/.linear_api_token`. -pub fn token_from_file() -> Result { - let path = token_file_path()?; - std::fs::read_to_string(&path) - .map(|s| s.trim().to_string()) - .map_err(|e| { - LinearError::AuthConfig(format!( - "Could not read token file {}: {}", - path.display(), - e - )) - }) +/// Resolve a Linear API token from a file at the given path. +pub fn token_from_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path).map_err(|e| { + LinearError::AuthConfig(format!( + "Could not read token file {}: {}", + path.display(), + e + )) + })?; + let token = content.trim().to_string(); + if token.is_empty() { + return Err(LinearError::AuthConfig(format!( + "Token file {} is empty", + path.display() + ))); + } + Ok(token) } /// Resolve a Linear API token from the environment variable `LINEAR_API_TOKEN`. @@ -31,18 +35,6 @@ pub fn token_from_env() -> Result { } } -/// Resolve a Linear API token with precedence: env var -> file. -/// (CLI flag takes highest precedence but is handled at the CLI layer.) -pub fn auto_token() -> Result { - token_from_env().or_else(|_| token_from_file()) -} - -fn token_file_path() -> Result { - let home = home::home_dir() - .ok_or_else(|| LinearError::AuthConfig("Could not determine home directory".to_string()))?; - Ok(home.join(".linear_api_token")) -} - #[cfg(test)] mod tests { use super::*; @@ -88,13 +80,6 @@ mod tests { }); } - #[test] - fn auto_token_prefers_env() { - with_env_token(Some("env-token-auto"), || { - assert_eq!(auto_token().unwrap(), "env-token-auto"); - }); - } - #[test] fn token_from_env_empty_string_is_treated_as_absent() { with_env_token(Some(""), || { @@ -117,9 +102,26 @@ mod tests { } #[test] - fn token_file_path_is_home_based() { - let path = token_file_path().unwrap(); - assert!(path.to_str().unwrap().contains(".linear_api_token")); - assert!(path.to_str().unwrap().starts_with("/")); + fn token_from_file_reads_and_trims() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(".linear_api_token"); + std::fs::write(&path, " my-token-123 \n").unwrap(); + assert_eq!(token_from_file(&path).unwrap(), "my-token-123"); + } + + #[test] + fn token_from_file_missing_file() { + let path = std::path::PathBuf::from("/tmp/nonexistent_token_file_xyz"); + let err = token_from_file(&path).unwrap_err(); + assert!(err.to_string().contains("nonexistent_token_file_xyz")); + } + + #[test] + fn token_from_file_empty_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(".linear_api_token"); + std::fs::write(&path, " \n").unwrap(); + let err = token_from_file(&path).unwrap_err(); + assert!(err.to_string().contains("empty")); } } diff --git a/crates/lineark-sdk/src/client.rs b/crates/lineark-sdk/src/client.rs index 626b01d..e87bb96 100644 --- a/crates/lineark-sdk/src/client.rs +++ b/crates/lineark-sdk/src/client.rs @@ -1,14 +1,15 @@ //! Async Linear API client. //! //! The primary entry point for interacting with Linear's GraphQL API. -//! Construct a [`Client`] via [`Client::auto`], [`Client::from_env`], -//! [`Client::from_file`], or [`Client::from_token`], then call generated -//! query and mutation methods. +//! Construct a [`Client`] via [`Client::from_token`], [`Client::from_env`], +//! or [`Client::from_token_file`], then call generated query and mutation +//! methods. use crate::auth; use crate::error::{GraphQLError, LinearError}; use crate::pagination::Connection; use serde::de::DeserializeOwned; +use std::path::Path; const LINEAR_API_URL: &str = "https://api.linear.app/graphql"; @@ -46,14 +47,9 @@ impl Client { Self::from_token(auth::token_from_env()?) } - /// Create a client from the `~/.linear_api_token` file. - pub fn from_file() -> Result { - Self::from_token(auth::token_from_file()?) - } - - /// Create a client by auto-detecting the token (env -> file). - pub fn auto() -> Result { - Self::from_token(auth::auto_token()?) + /// Create a client from a token file at the given path. + pub fn from_token_file(path: &Path) -> Result { + Self::from_token(auth::token_from_file(path)?) } /// Execute a GraphQL query and extract a single object from the response. diff --git a/crates/lineark-sdk/src/helpers.rs b/crates/lineark-sdk/src/helpers.rs index 1efbd5d..9138694 100644 --- a/crates/lineark-sdk/src/helpers.rs +++ b/crates/lineark-sdk/src/helpers.rs @@ -40,7 +40,7 @@ impl Client { /// /// ```no_run /// # async fn example() -> Result<(), lineark_sdk::LinearError> { - /// let client = lineark_sdk::Client::auto()?; + /// let client = lineark_sdk::Client::from_env()?; /// let result = client.download_url("https://uploads.linear.app/...").await?; /// std::fs::write("output.png", &result.bytes).unwrap(); /// # Ok(()) @@ -108,7 +108,7 @@ impl Client { /// /// ```no_run /// # async fn example() -> Result<(), lineark_sdk::LinearError> { - /// let client = lineark_sdk::Client::auto()?; + /// let client = lineark_sdk::Client::from_env()?; /// let bytes = std::fs::read("screenshot.png").unwrap(); /// let result = client /// .upload_file("screenshot.png", "image/png", bytes, false) diff --git a/crates/lineark-test-utils/src/token.rs b/crates/lineark-test-utils/src/token.rs index fa05c92..d571b8e 100644 --- a/crates/lineark-test-utils/src/token.rs +++ b/crates/lineark-test-utils/src/token.rs @@ -14,8 +14,6 @@ pub fn test_token() -> String { let path = home::home_dir() .expect("could not determine home directory") .join(".linear_api_token_test"); - std::fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("could not read {}: {}", path.display(), e)) - .trim() - .to_string() + lineark_sdk::auth::token_from_file(&path) + .unwrap_or_else(|e| panic!("could not read test token: {}", e)) } diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index 65c789f..1b04615 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -1,19 +1,66 @@ use crate::version_check; /// Print a compact LLM-friendly command reference (<1000 tokens). -pub async fn run() { +pub async fn run(active_profile: Option<&str>) { let env_hint = if std::env::var("LINEAR_API_TOKEN").is_ok() { " (set)" } else { "" }; - let file_hint = if std::env::var("HOME") - .map(|h| std::path::Path::new(&h).join(".linear_api_token").exists()) - .unwrap_or(false) - { - " (found)" + + let home = home::home_dir(); + + // Determine which token file to show on line 3, and build profile hints. + let active_name = match active_profile { + Some("default") | None => "default", + Some(p) => p, + }; + let token_file_display = if active_name == "default" { + "~/.linear_api_token".to_string() } else { - "" + format!("~/.linear_api_token_{active_name}") + }; + + let (file_hint, profile_extra_lines) = match &home { + Some(h) => { + let active_path = if active_name == "default" { + h.join(".linear_api_token") + } else { + h.join(format!(".linear_api_token_{active_name}")) + }; + let found = active_path.exists(); + + // Discover other profiles (excluding the active one). + let mut others: Vec = Vec::new(); + let default_exists = h.join(".linear_api_token").exists(); + if default_exists && active_name != "default" { + others.push("\"default\"".to_string()); + } + for p in crate::discover_profiles(h) { + if p != active_name { + others.push(format!("\"{p}\"")); + } + } + + let hint = if found { + format!(" (found, active profile: \"{active_name}\")") + } else { + " (not found)".to_string() + }; + + let extra = if others.is_empty() { + String::new() + } else { + format!( + "\n other available profiles: {}.\ + \n switch with --profile ", + others.join(", ") + ) + }; + + (hint, extra) + } + None => (String::new(), String::new()), }; print!( @@ -75,12 +122,13 @@ COMMANDS: GLOBAL OPTIONS: --api-token Override API token + --profile Use API token from ~/.linear_api_token_ --format human|json Force output format (auto-detected by default) AUTH (in precedence order): 1. --api-token flag 2. $LINEAR_API_TOKEN env var{env_hint} - 3. ~/.linear_api_token file{file_hint} + 3. {token_file_display} file{file_hint}{profile_extra_lines} "# ); diff --git a/crates/lineark/src/main.rs b/crates/lineark/src/main.rs index e8b2c39..ced6ec3 100644 --- a/crates/lineark/src/main.rs +++ b/crates/lineark/src/main.rs @@ -4,15 +4,20 @@ mod version_check; use clap::{Parser, Subcommand}; use lineark_sdk::Client; +use std::path::PathBuf; /// lineark — Linear CLI for humans and LLMs #[derive(Debug, Parser)] #[command(name = "lineark", version, about, after_help = update_hint_blocking())] struct Cli { /// API token (overrides $LINEAR_API_TOKEN and ~/.linear_api_token). - #[arg(long, global = true)] + #[arg(long, global = true, conflicts_with = "profile")] api_token: Option, + /// Use API token from ~/.linear_api_token_{name}. + #[arg(long, global = true, conflicts_with = "api_token")] + profile: Option, + /// Output format. Auto-detected if not specified (human for terminal, json for pipe). #[arg(long, global = true)] format: Option, @@ -76,6 +81,62 @@ pub fn format_update_hint(latest: Option<&str>) -> String { } } +/// Resolve the home directory or exit with error. +fn home_dir() -> PathBuf { + home::home_dir().unwrap_or_else(|| { + eprintln!("Error: could not determine home directory"); + std::process::exit(1); + }) +} + +/// Discover available profiles by globbing `~/.linear_api_token_*`. +/// Returns profile names (the suffix after `_`), excluding "test". +pub fn discover_profiles(home: &std::path::Path) -> Vec { + let prefix = ".linear_api_token_"; + let Ok(entries) = std::fs::read_dir(home) else { + return Vec::new(); + }; + let mut profiles: Vec = entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + let suffix = name.strip_prefix(prefix)?; + if suffix.is_empty() || suffix == "test" { + return None; + } + Some(suffix.to_string()) + }) + .collect(); + profiles.sort(); + profiles +} + +/// Format the error message when a profile file is not found. +fn profile_not_found_error(profile: &str, home: &std::path::Path) -> String { + let profiles = discover_profiles(home); + let default_exists = home.join(".linear_api_token").exists(); + + let mut available: Vec = Vec::new(); + if default_exists { + available.push("\"default\"".to_string()); + } + for p in &profiles { + available.push(format!("\"{}\"", p)); + } + + let mut msg = format!("Profile \"{}\" not found.", profile); + if available.is_empty() { + msg.push_str(" No profiles found."); + } else { + msg.push_str(&format!(" Available profiles: {}.", available.join(", "))); + } + msg.push_str(&format!( + "\nCreate it with:\n echo \"lin_api_...\" > ~/.linear_api_token_{}", + profile + )); + msg +} + #[tokio::main] async fn main() { let cli = Cli::parse(); @@ -84,7 +145,7 @@ async fn main() { // Handle commands that don't need auth. match cli.command { Command::Usage => { - commands::usage::run().await; + commands::usage::run(cli.profile.as_deref()).await; return; } Command::SelfCmd(cmd) => { @@ -98,9 +159,23 @@ async fn main() { } // Resolve client. - let client = match &cli.api_token { - Some(token) => Client::from_token(token), - None => Client::auto(), + let home = home_dir(); + let client = match (&cli.api_token, &cli.profile) { + (Some(_), Some(_)) => unreachable!(), // clap conflicts_with prevents this + (Some(token), None) => Client::from_token(token), + (None, Some(profile)) => { + let path = if profile == "default" { + home.join(".linear_api_token") + } else { + home.join(format!(".linear_api_token_{profile}")) + }; + Client::from_token_file(&path).map_err(|_| { + lineark_sdk::LinearError::AuthConfig(profile_not_found_error(profile, &home)) + }) + } + (None, None) => { + Client::from_env().or_else(|_| Client::from_token_file(&home.join(".linear_api_token"))) + } }; let client = match client { Ok(c) => c, diff --git a/crates/lineark/tests/offline.rs b/crates/lineark/tests/offline.rs index 8cf412d..84447a6 100644 --- a/crates/lineark/tests/offline.rs +++ b/crates/lineark/tests/offline.rs @@ -1164,3 +1164,264 @@ fn issues_update_no_flags_error_mentions_estimate() { .failure() .stderr(predicate::str::contains("--estimate")); } + +// ── Profile support ───────────────────────────────────────────────────────── + +#[test] +fn profile_and_api_token_conflict() { + lineark() + .args(["--api-token", "fake", "--profile", "work", "whoami"]) + .assert() + .failure() + .stderr(predicate::str::contains("cannot be used with")); +} + +#[test] +fn help_shows_profile_flag() { + lineark() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--profile")); +} + +#[test] +fn usage_shows_profile_flag() { + lineark() + .arg("usage") + .assert() + .success() + .stdout(predicate::str::contains("--profile")); +} + +#[test] +fn profile_missing_file_shows_error_with_profile_name() { + // Use a tmpdir as HOME so no token files exist. + let tmpdir = tempfile::tempdir().unwrap(); + lineark() + .env("HOME", tmpdir.path()) + .env_remove("LINEAR_API_TOKEN") + .args(["--profile", "nonexistent", "whoami"]) + .assert() + .failure() + .stderr(predicate::str::contains( + "Profile \"nonexistent\" not found", + )) + .stderr(predicate::str::contains("No profiles found")) + .stderr(predicate::str::contains( + "echo \"lin_api_...\" > ~/.linear_api_token_nonexistent", + )); +} + +#[test] +fn profile_missing_file_shows_available_profiles() { + let tmpdir = tempfile::tempdir().unwrap(); + // Create a "banana" profile and the default token file. + std::fs::write(tmpdir.path().join(".linear_api_token"), "tok-default").unwrap(); + std::fs::write(tmpdir.path().join(".linear_api_token_banana"), "tok-banana").unwrap(); + + lineark() + .env("HOME", tmpdir.path()) + .env_remove("LINEAR_API_TOKEN") + .args(["--profile", "nonexistent", "whoami"]) + .assert() + .failure() + .stderr(predicate::str::contains("\"default\"")) + .stderr(predicate::str::contains("\"banana\"")); +} + +#[test] +fn profile_reads_named_token_file() { + let tmpdir = tempfile::tempdir().unwrap(); + std::fs::write( + tmpdir.path().join(".linear_api_token_work"), + "fake-work-token", + ) + .unwrap(); + + // The token is fake, so the API call fails — but we verify it gets past auth resolution. + lineark() + .env("HOME", tmpdir.path()) + .env_remove("LINEAR_API_TOKEN") + .args(["--profile", "work", "whoami"]) + .assert() + .failure() + // Should NOT be an auth config error — should be an API error. + .stderr(predicate::str::contains("Profile").not()); +} + +#[test] +fn profile_skips_env_var() { + // Even with LINEAR_API_TOKEN set, --profile should use the file. + let tmpdir = tempfile::tempdir().unwrap(); + std::fs::write( + tmpdir.path().join(".linear_api_token_work"), + "profile-token", + ) + .unwrap(); + + lineark() + .env("HOME", tmpdir.path()) + .env("LINEAR_API_TOKEN", "env-token-should-be-ignored") + .args(["--profile", "work", "whoami"]) + .assert() + .failure() + // Should fail on API call, not auth — profile file exists. + .stderr(predicate::str::contains("Profile").not()); +} + +#[test] +fn default_auth_uses_env_then_file() { + let tmpdir = tempfile::tempdir().unwrap(); + std::fs::write(tmpdir.path().join(".linear_api_token"), "file-token").unwrap(); + + // Without --profile or --api-token, should use file token (env not set). + lineark() + .env("HOME", tmpdir.path()) + .env_remove("LINEAR_API_TOKEN") + .args(["whoami"]) + .assert() + .failure() + // Should fail on API call, not auth. + .stderr(predicate::str::contains("token").not()); +} + +#[test] +fn usage_discovers_profiles() { + let tmpdir = tempfile::tempdir().unwrap(); + std::fs::write(tmpdir.path().join(".linear_api_token"), "tok").unwrap(); + std::fs::write(tmpdir.path().join(".linear_api_token_work"), "tok").unwrap(); + std::fs::write(tmpdir.path().join(".linear_api_token_banana"), "tok").unwrap(); + // _test should be filtered out. + std::fs::write(tmpdir.path().join(".linear_api_token_test"), "tok").unwrap(); + + lineark() + .env("HOME", tmpdir.path()) + .env_remove("LINEAR_API_TOKEN") + .arg("usage") + .assert() + .success() + .stdout(predicate::str::contains(r#"active profile: "default")"#)) + .stdout(predicate::str::contains( + r#"other available profiles: "banana", "work"."#, + )) + .stdout(predicate::str::contains("switch with --profile ")) + // _test filtered out. + .stdout(predicate::str::contains("\"test\"").not()); +} + +#[test] +fn profile_default_reads_default_token_file() { + let tmpdir = tempfile::tempdir().unwrap(); + std::fs::write( + tmpdir.path().join(".linear_api_token"), + "fake-default-token", + ) + .unwrap(); + + // --profile default should use ~/.linear_api_token (not ~/.linear_api_token_default). + lineark() + .env("HOME", tmpdir.path()) + .env_remove("LINEAR_API_TOKEN") + .args(["--profile", "default", "whoami"]) + .assert() + .failure() + // Should fail on API call, not auth config. + .stderr(predicate::str::contains("Profile").not()); +} + +#[test] +fn usage_default_active_no_others() { + // Only the default profile exists — no extra lines. + let tmpdir = tempfile::tempdir().unwrap(); + std::fs::write(tmpdir.path().join(".linear_api_token"), "tok").unwrap(); + + lineark() + .env("HOME", tmpdir.path()) + .env_remove("LINEAR_API_TOKEN") + .arg("usage") + .assert() + .success() + .stdout(predicate::str::contains( + r#"(found, active profile: "default")"#, + )) + .stdout(predicate::str::contains("other available").not()); +} + +#[test] +fn usage_default_active_with_others() { + let tmpdir = tempfile::tempdir().unwrap(); + std::fs::write(tmpdir.path().join(".linear_api_token"), "tok").unwrap(); + std::fs::write(tmpdir.path().join(".linear_api_token_work"), "tok").unwrap(); + + lineark() + .env("HOME", tmpdir.path()) + .env_remove("LINEAR_API_TOKEN") + .arg("usage") + .assert() + .success() + .stdout(predicate::str::contains( + r#"(found, active profile: "default")"#, + )) + .stdout(predicate::str::contains( + r#"other available profiles: "work"."#, + )) + .stdout(predicate::str::contains("switch with --profile ")); +} + +#[test] +fn usage_with_named_profile_active() { + let tmpdir = tempfile::tempdir().unwrap(); + std::fs::write(tmpdir.path().join(".linear_api_token"), "tok").unwrap(); + std::fs::write(tmpdir.path().join(".linear_api_token_work"), "tok").unwrap(); + + lineark() + .env("HOME", tmpdir.path()) + .env_remove("LINEAR_API_TOKEN") + .args(["--profile", "work", "usage"]) + .assert() + .success() + .stdout(predicate::str::contains( + r#"~/.linear_api_token_work file (found, active profile: "work")"#, + )) + .stdout(predicate::str::contains( + r#"other available profiles: "default"."#, + )); +} + +#[test] +fn usage_with_missing_profile_shows_not_found() { + let tmpdir = tempfile::tempdir().unwrap(); + std::fs::write(tmpdir.path().join(".linear_api_token"), "tok").unwrap(); + + lineark() + .env("HOME", tmpdir.path()) + .env_remove("LINEAR_API_TOKEN") + .args(["--profile", "missing", "usage"]) + .assert() + .success() + .stdout(predicate::str::contains( + "~/.linear_api_token_missing file (not found)", + )) + .stdout(predicate::str::contains( + r#"other available profiles: "default"."#, + )) + .stdout(predicate::str::contains("switch with --profile ")); +} + +#[test] +fn usage_with_missing_profile_no_others() { + // No profiles exist at all — just "(not found)", no extra lines. + let tmpdir = tempfile::tempdir().unwrap(); + + lineark() + .env("HOME", tmpdir.path()) + .env_remove("LINEAR_API_TOKEN") + .args(["--profile", "missing", "usage"]) + .assert() + .success() + .stdout(predicate::str::contains( + "~/.linear_api_token_missing file (not found)", + )) + .stdout(predicate::str::contains("other available").not()); +} From 06377c4f8dc89f9acf62c896eff6a0480fbe710d Mon Sep 17 00:00:00 2001 From: Cadu Date: Sun, 8 Mar 2026 16:57:34 -0300 Subject: [PATCH 2/7] refactor: extract profile module, update docs, add edge-case tests - Extract profile.rs module from inline code in main.rs and usage.rs - Remove `home` crate from SDK (no longer needed since SDK is path-agnostic) - Add test helper `lineark_with_profiles()` to reduce test boilerplate - Add edge-case tests: hyphenated names, dotted names, whitespace-only tokens - Fix stale SDK API references in MASTERPLAN.md and top-level README - Fix mutation signatures in SDK README (comment_update, comment_resolve, issue_label_create, issue_label_update) - Document --profile feature in CLI README and top-level README --- Cargo.lock | 1 - README.md | 9 +- crates/lineark-sdk/Cargo.toml | 1 - crates/lineark-sdk/README.md | 8 +- crates/lineark/README.md | 16 +++ crates/lineark/src/commands/usage.rs | 19 +-- crates/lineark/src/main.rs | 64 +-------- crates/lineark/tests/offline.rs | 193 ++++++++++++--------------- docs/MASTERPLAN.md | 4 +- 9 files changed, 123 insertions(+), 192 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 369452c..f076f5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -905,7 +905,6 @@ name = "lineark-sdk" version = "0.0.0" dependencies = [ "chrono", - "home", "libtest-with", "lineark-derive", "lineark-test-utils", diff --git a/README.md b/README.md index e3d0947..f1e4480 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,13 @@ Create a [Linear Personal API key](https://linear.app/settings/account/security) echo "lin_api_..." > ~/.linear_api_token ``` +For multiple workspaces, use named profiles (`~/.linear_api_token_{name}`) and switch with `--profile`: + +```sh +echo "lin_api_..." > ~/.linear_api_token_work +lineark --profile work whoami +``` + ### Use it ```sh @@ -100,7 +107,7 @@ use lineark_sdk::generated::types::{User, Team, IssueSearchResult}; #[tokio::main] async fn main() -> Result<(), Box> { - let client = Client::auto()?; + let client = Client::from_env()?; let me = client.whoami::().await?; println!("{:?}", me); diff --git a/crates/lineark-sdk/Cargo.toml b/crates/lineark-sdk/Cargo.toml index a29e665..b38d96a 100644 --- a/crates/lineark-sdk/Cargo.toml +++ b/crates/lineark-sdk/Cargo.toml @@ -14,7 +14,6 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros"] } serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = { version = "0.4", features = ["serde"] } -home = "0.5" url = "2" lineark-derive = { path = "../lineark-derive", version = "0.0.0" } diff --git a/crates/lineark-sdk/README.md b/crates/lineark-sdk/README.md index 62aa0d2..5ae96b6 100644 --- a/crates/lineark-sdk/README.md +++ b/crates/lineark-sdk/README.md @@ -169,12 +169,12 @@ let payload = client.issue_create::(IssueCreateInput { | `issue_delete(permanently, id)` | Delete an issue | | `issue_vcs_branch_search(branch)` | Find issue by Git branch name | | `comment_create(input)` | Create a comment | -| `comment_update(input, id)` | Update a comment | -| `comment_resolve(input, id)` | Resolve a comment thread | +| `comment_update(skip_edited_at, input, id)` | Update a comment | +| `comment_resolve(resolving_comment_id, id)` | Resolve a comment thread | | `comment_unresolve(id)` | Unresolve a comment thread | | `comment_delete(id)` | Delete a comment | -| `issue_label_create(input)` | Create an issue label | -| `issue_label_update(input, id)` | Update an issue label | +| `issue_label_create(replace_team_labels, input)` | Create an issue label | +| `issue_label_update(replace_team_labels, input, id)` | Update an issue label | | `issue_label_delete(id)` | Delete an issue label | | `issue_relation_create(override_created_at, input)` | Create an issue relation | | `issue_relation_delete(id)` | Delete an issue relation | diff --git a/crates/lineark/README.md b/crates/lineark/README.md index 8d7799a..3bcebca 100644 --- a/crates/lineark/README.md +++ b/crates/lineark/README.md @@ -32,6 +32,22 @@ echo "lin_api_..." > ~/.linear_api_token Or use an environment variable (`LINEAR_API_TOKEN`) or the `--api-token` flag. +### Multiple profiles + +Store tokens for different workspaces in named files: + +```sh +echo "lin_api_..." > ~/.linear_api_token_work +echo "lin_api_..." > ~/.linear_api_token_personal +``` + +Then switch with `--profile`: + +```sh +lineark --profile work issues list --mine +lineark --profile personal whoami +``` + ## Usage Most flags accept human-readable names or UUIDs — `--team` accepts key/name/UUID, `--assignee` accepts user name/display name, `--labels` accepts label names, `--project` and `--cycle` accept names. `me` is a special alias that resolves to the authenticated user on `--assignee`, `--lead`, and `--members`. diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index 1b04615..a013c3a 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -1,3 +1,4 @@ +use crate::profile; use crate::version_check; /// Print a compact LLM-friendly command reference (<1000 tokens). @@ -15,28 +16,18 @@ pub async fn run(active_profile: Option<&str>) { Some("default") | None => "default", Some(p) => p, }; - let token_file_display = if active_name == "default" { - "~/.linear_api_token".to_string() - } else { - format!("~/.linear_api_token_{active_name}") - }; + let token_file_display = profile::display_path(active_name); let (file_hint, profile_extra_lines) = match &home { Some(h) => { - let active_path = if active_name == "default" { - h.join(".linear_api_token") - } else { - h.join(format!(".linear_api_token_{active_name}")) - }; - let found = active_path.exists(); + let found = profile::token_path(h, active_name).exists(); // Discover other profiles (excluding the active one). let mut others: Vec = Vec::new(); - let default_exists = h.join(".linear_api_token").exists(); - if default_exists && active_name != "default" { + if h.join(".linear_api_token").exists() && active_name != "default" { others.push("\"default\"".to_string()); } - for p in crate::discover_profiles(h) { + for p in profile::discover(h) { if p != active_name { others.push(format!("\"{p}\"")); } diff --git a/crates/lineark/src/main.rs b/crates/lineark/src/main.rs index ced6ec3..96e87f5 100644 --- a/crates/lineark/src/main.rs +++ b/crates/lineark/src/main.rs @@ -1,5 +1,6 @@ mod commands; mod output; +pub mod profile; mod version_check; use clap::{Parser, Subcommand}; @@ -89,54 +90,6 @@ fn home_dir() -> PathBuf { }) } -/// Discover available profiles by globbing `~/.linear_api_token_*`. -/// Returns profile names (the suffix after `_`), excluding "test". -pub fn discover_profiles(home: &std::path::Path) -> Vec { - let prefix = ".linear_api_token_"; - let Ok(entries) = std::fs::read_dir(home) else { - return Vec::new(); - }; - let mut profiles: Vec = entries - .filter_map(|e| e.ok()) - .filter_map(|e| { - let name = e.file_name().to_string_lossy().to_string(); - let suffix = name.strip_prefix(prefix)?; - if suffix.is_empty() || suffix == "test" { - return None; - } - Some(suffix.to_string()) - }) - .collect(); - profiles.sort(); - profiles -} - -/// Format the error message when a profile file is not found. -fn profile_not_found_error(profile: &str, home: &std::path::Path) -> String { - let profiles = discover_profiles(home); - let default_exists = home.join(".linear_api_token").exists(); - - let mut available: Vec = Vec::new(); - if default_exists { - available.push("\"default\"".to_string()); - } - for p in &profiles { - available.push(format!("\"{}\"", p)); - } - - let mut msg = format!("Profile \"{}\" not found.", profile); - if available.is_empty() { - msg.push_str(" No profiles found."); - } else { - msg.push_str(&format!(" Available profiles: {}.", available.join(", "))); - } - msg.push_str(&format!( - "\nCreate it with:\n echo \"lin_api_...\" > ~/.linear_api_token_{}", - profile - )); - msg -} - #[tokio::main] async fn main() { let cli = Cli::parse(); @@ -163,19 +116,14 @@ async fn main() { let client = match (&cli.api_token, &cli.profile) { (Some(_), Some(_)) => unreachable!(), // clap conflicts_with prevents this (Some(token), None) => Client::from_token(token), - (None, Some(profile)) => { - let path = if profile == "default" { - home.join(".linear_api_token") - } else { - home.join(format!(".linear_api_token_{profile}")) - }; + (None, Some(name)) => { + let path = profile::token_path(&home, name); Client::from_token_file(&path).map_err(|_| { - lineark_sdk::LinearError::AuthConfig(profile_not_found_error(profile, &home)) + lineark_sdk::LinearError::AuthConfig(profile::not_found_error(name, &home)) }) } - (None, None) => { - Client::from_env().or_else(|_| Client::from_token_file(&home.join(".linear_api_token"))) - } + (None, None) => Client::from_env() + .or_else(|_| Client::from_token_file(&profile::token_path(&home, "default"))), }; let client = match client { Ok(c) => c, diff --git a/crates/lineark/tests/offline.rs b/crates/lineark/tests/offline.rs index 84447a6..bb987a9 100644 --- a/crates/lineark/tests/offline.rs +++ b/crates/lineark/tests/offline.rs @@ -11,6 +11,27 @@ fn lineark() -> Command { Command::cargo_bin("lineark").unwrap() } +// ── Profile test helper ───────────────────────────────────────────────────── + +/// Set up a temp HOME with profile token files and return a pre-configured command. +/// Pass `("default", "tok")` for `~/.linear_api_token`, or `("work", "tok")` for +/// `~/.linear_api_token_work`. +fn lineark_with_profiles(profiles: &[(&str, &str)]) -> (tempfile::TempDir, Command) { + let tmpdir = tempfile::tempdir().unwrap(); + for (name, token) in profiles { + let filename = if *name == "default" { + ".linear_api_token".to_string() + } else { + format!(".linear_api_token_{name}") + }; + std::fs::write(tmpdir.path().join(filename), token).unwrap(); + } + let mut cmd = lineark(); + cmd.env("HOME", tmpdir.path()); + cmd.env_remove("LINEAR_API_TOKEN"); + (tmpdir, cmd) +} + // ── Usage command ─────────────────────────────────────────────────────────── #[test] @@ -34,6 +55,7 @@ fn usage_mentions_global_options() { .assert() .success() .stdout(predicate::str::contains("--api-token")) + .stdout(predicate::str::contains("--profile")) .stdout(predicate::str::contains("--format")); } @@ -1196,34 +1218,20 @@ fn usage_shows_profile_flag() { #[test] fn profile_missing_file_shows_error_with_profile_name() { - // Use a tmpdir as HOME so no token files exist. - let tmpdir = tempfile::tempdir().unwrap(); - lineark() - .env("HOME", tmpdir.path()) - .env_remove("LINEAR_API_TOKEN") - .args(["--profile", "nonexistent", "whoami"]) + let (_tmpdir, mut cmd) = lineark_with_profiles(&[]); + cmd.args(["--profile", "nonexistent", "whoami"]) .assert() .failure() - .stderr(predicate::str::contains( - "Profile \"nonexistent\" not found", - )) + .stderr(predicate::str::contains("not found")) .stderr(predicate::str::contains("No profiles found")) - .stderr(predicate::str::contains( - "echo \"lin_api_...\" > ~/.linear_api_token_nonexistent", - )); + .stderr(predicate::str::contains("~/.linear_api_token_nonexistent")); } #[test] fn profile_missing_file_shows_available_profiles() { - let tmpdir = tempfile::tempdir().unwrap(); - // Create a "banana" profile and the default token file. - std::fs::write(tmpdir.path().join(".linear_api_token"), "tok-default").unwrap(); - std::fs::write(tmpdir.path().join(".linear_api_token_banana"), "tok-banana").unwrap(); - - lineark() - .env("HOME", tmpdir.path()) - .env_remove("LINEAR_API_TOKEN") - .args(["--profile", "nonexistent", "whoami"]) + let (_tmpdir, mut cmd) = + lineark_with_profiles(&[("default", "tok-default"), ("banana", "tok-banana")]); + cmd.args(["--profile", "nonexistent", "whoami"]) .assert() .failure() .stderr(predicate::str::contains("\"default\"")) @@ -1232,18 +1240,9 @@ fn profile_missing_file_shows_available_profiles() { #[test] fn profile_reads_named_token_file() { - let tmpdir = tempfile::tempdir().unwrap(); - std::fs::write( - tmpdir.path().join(".linear_api_token_work"), - "fake-work-token", - ) - .unwrap(); - + let (_tmpdir, mut cmd) = lineark_with_profiles(&[("work", "fake-work-token")]); // The token is fake, so the API call fails — but we verify it gets past auth resolution. - lineark() - .env("HOME", tmpdir.path()) - .env_remove("LINEAR_API_TOKEN") - .args(["--profile", "work", "whoami"]) + cmd.args(["--profile", "work", "whoami"]) .assert() .failure() // Should NOT be an auth config error — should be an API error. @@ -1252,17 +1251,9 @@ fn profile_reads_named_token_file() { #[test] fn profile_skips_env_var() { + let (_tmpdir, mut cmd) = lineark_with_profiles(&[("work", "profile-token")]); // Even with LINEAR_API_TOKEN set, --profile should use the file. - let tmpdir = tempfile::tempdir().unwrap(); - std::fs::write( - tmpdir.path().join(".linear_api_token_work"), - "profile-token", - ) - .unwrap(); - - lineark() - .env("HOME", tmpdir.path()) - .env("LINEAR_API_TOKEN", "env-token-should-be-ignored") + cmd.env("LINEAR_API_TOKEN", "env-token-should-be-ignored") .args(["--profile", "work", "whoami"]) .assert() .failure() @@ -1272,14 +1263,9 @@ fn profile_skips_env_var() { #[test] fn default_auth_uses_env_then_file() { - let tmpdir = tempfile::tempdir().unwrap(); - std::fs::write(tmpdir.path().join(".linear_api_token"), "file-token").unwrap(); - + let (_tmpdir, mut cmd) = lineark_with_profiles(&[("default", "file-token")]); // Without --profile or --api-token, should use file token (env not set). - lineark() - .env("HOME", tmpdir.path()) - .env_remove("LINEAR_API_TOKEN") - .args(["whoami"]) + cmd.args(["whoami"]) .assert() .failure() // Should fail on API call, not auth. @@ -1288,17 +1274,13 @@ fn default_auth_uses_env_then_file() { #[test] fn usage_discovers_profiles() { - let tmpdir = tempfile::tempdir().unwrap(); - std::fs::write(tmpdir.path().join(".linear_api_token"), "tok").unwrap(); - std::fs::write(tmpdir.path().join(".linear_api_token_work"), "tok").unwrap(); - std::fs::write(tmpdir.path().join(".linear_api_token_banana"), "tok").unwrap(); - // _test should be filtered out. - std::fs::write(tmpdir.path().join(".linear_api_token_test"), "tok").unwrap(); - - lineark() - .env("HOME", tmpdir.path()) - .env_remove("LINEAR_API_TOKEN") - .arg("usage") + let (_tmpdir, mut cmd) = lineark_with_profiles(&[ + ("default", "tok"), + ("work", "tok"), + ("banana", "tok"), + ("test", "tok"), // _test should be filtered out + ]); + cmd.arg("usage") .assert() .success() .stdout(predicate::str::contains(r#"active profile: "default")"#)) @@ -1306,24 +1288,14 @@ fn usage_discovers_profiles() { r#"other available profiles: "banana", "work"."#, )) .stdout(predicate::str::contains("switch with --profile ")) - // _test filtered out. .stdout(predicate::str::contains("\"test\"").not()); } #[test] fn profile_default_reads_default_token_file() { - let tmpdir = tempfile::tempdir().unwrap(); - std::fs::write( - tmpdir.path().join(".linear_api_token"), - "fake-default-token", - ) - .unwrap(); - + let (_tmpdir, mut cmd) = lineark_with_profiles(&[("default", "fake-default-token")]); // --profile default should use ~/.linear_api_token (not ~/.linear_api_token_default). - lineark() - .env("HOME", tmpdir.path()) - .env_remove("LINEAR_API_TOKEN") - .args(["--profile", "default", "whoami"]) + cmd.args(["--profile", "default", "whoami"]) .assert() .failure() // Should fail on API call, not auth config. @@ -1332,14 +1304,8 @@ fn profile_default_reads_default_token_file() { #[test] fn usage_default_active_no_others() { - // Only the default profile exists — no extra lines. - let tmpdir = tempfile::tempdir().unwrap(); - std::fs::write(tmpdir.path().join(".linear_api_token"), "tok").unwrap(); - - lineark() - .env("HOME", tmpdir.path()) - .env_remove("LINEAR_API_TOKEN") - .arg("usage") + let (_tmpdir, mut cmd) = lineark_with_profiles(&[("default", "tok")]); + cmd.arg("usage") .assert() .success() .stdout(predicate::str::contains( @@ -1350,14 +1316,8 @@ fn usage_default_active_no_others() { #[test] fn usage_default_active_with_others() { - let tmpdir = tempfile::tempdir().unwrap(); - std::fs::write(tmpdir.path().join(".linear_api_token"), "tok").unwrap(); - std::fs::write(tmpdir.path().join(".linear_api_token_work"), "tok").unwrap(); - - lineark() - .env("HOME", tmpdir.path()) - .env_remove("LINEAR_API_TOKEN") - .arg("usage") + let (_tmpdir, mut cmd) = lineark_with_profiles(&[("default", "tok"), ("work", "tok")]); + cmd.arg("usage") .assert() .success() .stdout(predicate::str::contains( @@ -1371,14 +1331,8 @@ fn usage_default_active_with_others() { #[test] fn usage_with_named_profile_active() { - let tmpdir = tempfile::tempdir().unwrap(); - std::fs::write(tmpdir.path().join(".linear_api_token"), "tok").unwrap(); - std::fs::write(tmpdir.path().join(".linear_api_token_work"), "tok").unwrap(); - - lineark() - .env("HOME", tmpdir.path()) - .env_remove("LINEAR_API_TOKEN") - .args(["--profile", "work", "usage"]) + let (_tmpdir, mut cmd) = lineark_with_profiles(&[("default", "tok"), ("work", "tok")]); + cmd.args(["--profile", "work", "usage"]) .assert() .success() .stdout(predicate::str::contains( @@ -1391,13 +1345,8 @@ fn usage_with_named_profile_active() { #[test] fn usage_with_missing_profile_shows_not_found() { - let tmpdir = tempfile::tempdir().unwrap(); - std::fs::write(tmpdir.path().join(".linear_api_token"), "tok").unwrap(); - - lineark() - .env("HOME", tmpdir.path()) - .env_remove("LINEAR_API_TOKEN") - .args(["--profile", "missing", "usage"]) + let (_tmpdir, mut cmd) = lineark_with_profiles(&[("default", "tok")]); + cmd.args(["--profile", "missing", "usage"]) .assert() .success() .stdout(predicate::str::contains( @@ -1411,13 +1360,8 @@ fn usage_with_missing_profile_shows_not_found() { #[test] fn usage_with_missing_profile_no_others() { - // No profiles exist at all — just "(not found)", no extra lines. - let tmpdir = tempfile::tempdir().unwrap(); - - lineark() - .env("HOME", tmpdir.path()) - .env_remove("LINEAR_API_TOKEN") - .args(["--profile", "missing", "usage"]) + let (_tmpdir, mut cmd) = lineark_with_profiles(&[]); + cmd.args(["--profile", "missing", "usage"]) .assert() .success() .stdout(predicate::str::contains( @@ -1425,3 +1369,32 @@ fn usage_with_missing_profile_no_others() { )) .stdout(predicate::str::contains("other available").not()); } + +// ── Profile edge cases ────────────────────────────────────────────────────── + +#[test] +fn profile_with_hyphenated_name() { + let (_tmpdir, mut cmd) = lineark_with_profiles(&[("work-prod", "fake-token")]); + cmd.args(["--profile", "work-prod", "whoami"]) + .assert() + .failure() + .stderr(predicate::str::contains("Profile").not()); +} + +#[test] +fn profile_with_dotted_name() { + let (_tmpdir, mut cmd) = lineark_with_profiles(&[("my.company", "fake-token")]); + cmd.args(["--profile", "my.company", "whoami"]) + .assert() + .failure() + .stderr(predicate::str::contains("Profile").not()); +} + +#[test] +fn profile_whitespace_only_token_file_shows_error() { + let (_tmpdir, mut cmd) = lineark_with_profiles(&[("bad", " \n")]); + cmd.args(["--profile", "bad", "whoami"]) + .assert() + .failure() + .stderr(predicate::str::contains("Error")); +} diff --git a/docs/MASTERPLAN.md b/docs/MASTERPLAN.md index 10246a1..a75073d 100644 --- a/docs/MASTERPLAN.md +++ b/docs/MASTERPLAN.md @@ -130,9 +130,7 @@ let client = Client::from_token("lin_api_...")?; // or let client = Client::from_env()?; // LINEAR_API_TOKEN env var // or -let client = Client::from_file()?; // ~/.linear_api_token -// or -let client = Client::auto()?; // tries env -> file (same precedence as CLI) +let client = Client::from_token_file(Path::new("/path/to/token"))?; // any file path let me = client.whoami().await?; let teams = client.teams().send().await?; From bdc7dcff49487ee032830e012f691f87d3eab61f Mon Sep 17 00:00:00 2001 From: Cadu Date: Sun, 8 Mar 2026 17:10:01 -0300 Subject: [PATCH 3/7] fix: add missing profile.rs module --- crates/lineark/src/profile.rs | 73 +++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 crates/lineark/src/profile.rs diff --git a/crates/lineark/src/profile.rs b/crates/lineark/src/profile.rs new file mode 100644 index 0000000..8c7dd76 --- /dev/null +++ b/crates/lineark/src/profile.rs @@ -0,0 +1,73 @@ +//! Profile utilities for multi-token auth. +//! +//! Profiles are named token files: `~/.linear_api_token_{name}`. +//! The "default" profile maps to `~/.linear_api_token` (no suffix). + +use std::path::{Path, PathBuf}; + +/// Resolve the token file path for a profile name. +/// "default" maps to `~/.linear_api_token`, others to `~/.linear_api_token_{name}`. +pub fn token_path(home: &Path, name: &str) -> PathBuf { + if name == "default" { + home.join(".linear_api_token") + } else { + home.join(format!(".linear_api_token_{name}")) + } +} + +/// Display path for a profile (tilde-prefixed, for user-facing output). +pub fn display_path(name: &str) -> String { + if name == "default" { + "~/.linear_api_token".to_string() + } else { + format!("~/.linear_api_token_{name}") + } +} + +/// Discover available profiles by scanning `~/.linear_api_token_*`. +/// Returns profile names (the suffix after `_`), sorted, excluding "test". +pub fn discover(home: &Path) -> Vec { + let prefix = ".linear_api_token_"; + let Ok(entries) = std::fs::read_dir(home) else { + return Vec::new(); + }; + let mut profiles: Vec = entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + let suffix = name.strip_prefix(prefix)?; + if suffix.is_empty() || suffix == "test" { + return None; + } + Some(suffix.to_string()) + }) + .collect(); + profiles.sort(); + profiles +} + +/// Format the error message when a profile file is not found. +pub fn not_found_error(profile: &str, home: &Path) -> String { + let profiles = discover(home); + let default_exists = home.join(".linear_api_token").exists(); + + let mut available: Vec = Vec::new(); + if default_exists { + available.push("\"default\"".to_string()); + } + for p in &profiles { + available.push(format!("\"{p}\"")); + } + + let mut msg = format!("Profile \"{profile}\" not found."); + if available.is_empty() { + msg.push_str(" No profiles found."); + } else { + msg.push_str(&format!(" Available profiles: {}.", available.join(", "))); + } + msg.push_str(&format!( + "\nCreate it with:\n echo \"lin_api_...\" > {}", + display_path(profile) + )); + msg +} From 16a9563d19523133eb6494ac4563076bf45618b1 Mon Sep 17 00:00:00 2001 From: Cadu Date: Sun, 8 Mar 2026 17:47:23 -0300 Subject: [PATCH 4/7] docs: add --profile default example to CLI README --- crates/lineark/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/lineark/README.md b/crates/lineark/README.md index 3bcebca..dd81961 100644 --- a/crates/lineark/README.md +++ b/crates/lineark/README.md @@ -46,6 +46,7 @@ Then switch with `--profile`: ```sh lineark --profile work issues list --mine lineark --profile personal whoami +lineark --profile default whoami # explicitly use ~/.linear_api_token ``` ## Usage From 0718d2fd6f4d3357bea208963b3b61d6dcf68454 Mon Sep 17 00:00:00 2001 From: Cadu Date: Sun, 8 Mar 2026 17:48:52 -0300 Subject: [PATCH 5/7] docs: show default + named profile token files in CLI README --- crates/lineark/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/lineark/README.md b/crates/lineark/README.md index dd81961..73ad049 100644 --- a/crates/lineark/README.md +++ b/crates/lineark/README.md @@ -37,16 +37,16 @@ Or use an environment variable (`LINEAR_API_TOKEN`) or the `--api-token` flag. Store tokens for different workspaces in named files: ```sh -echo "lin_api_..." > ~/.linear_api_token_work -echo "lin_api_..." > ~/.linear_api_token_personal +echo "lin_api_..." > ~/.linear_api_token # "default" API token +echo "lin_api_..." > ~/.linear_api_token_work # "work" API token +echo "lin_api_..." > ~/.linear_api_token_freelance # "freelance" API token ``` Then switch with `--profile`: ```sh lineark --profile work issues list --mine -lineark --profile personal whoami -lineark --profile default whoami # explicitly use ~/.linear_api_token +lineark --profile freelance whoami ``` ## Usage From 2f12988748dbf7c3d3b50c178455847a7c706d2d Mon Sep 17 00:00:00 2001 From: Cadu Date: Sun, 8 Mar 2026 17:50:40 -0300 Subject: [PATCH 6/7] docs: clarify which token file each --profile example uses --- crates/lineark/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/lineark/README.md b/crates/lineark/README.md index 73ad049..ea39b79 100644 --- a/crates/lineark/README.md +++ b/crates/lineark/README.md @@ -45,8 +45,9 @@ echo "lin_api_..." > ~/.linear_api_token_freelance # "freelance" API token Then switch with `--profile`: ```sh -lineark --profile work issues list --mine -lineark --profile freelance whoami +lineark whoami # uses ~/.linear_api_token (default) +lineark --profile work issues list --mine # uses ~/.linear_api_token_work +lineark --profile freelance whoami # uses ~/.linear_api_token_freelance ``` ## Usage From 8be06269fd0f539670332e79a246617b69bbe772 Mon Sep 17 00:00:00 2001 From: Cadu Date: Sun, 8 Mar 2026 17:54:39 -0300 Subject: [PATCH 7/7] chore: retrigger CI