Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -100,7 +107,7 @@ use lineark_sdk::generated::types::{User, Team, IssueSearchResult};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::auto()?;
let client = Client::from_env()?;

let me = client.whoami::<User>().await?;
println!("{:?}", me);
Expand Down
1 change: 0 additions & 1 deletion crates/lineark-sdk/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }

Expand Down
15 changes: 7 additions & 8 deletions crates/lineark-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use lineark_sdk::generated::types::{User, Team};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::auto()?;
let client = Client::from_env()?;

let me = client.whoami::<User>().await?;
println!("Logged in as: {}", me.name.as_deref().unwrap_or("?"));
Expand All @@ -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

Expand Down Expand Up @@ -113,7 +112,7 @@ struct LeanIssue {
title: Option<String>,
}

let client = Client::auto()?;
let client = Client::from_env()?;
let issues = client.issues::<LeanIssue>().first(10).send().await?;
for issue in &issues.nodes {
println!("{}", issue.title.as_deref().unwrap_or("?"));
Expand Down Expand Up @@ -170,12 +169,12 @@ let payload = client.issue_create::<Issue>(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 |
Expand Down
78 changes: 40 additions & 38 deletions crates/lineark-sdk/src/auth.rs
Original file line number Diff line number Diff line change
@@ -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<String, LinearError> {
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<String, LinearError> {
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`.
Expand All @@ -31,18 +35,6 @@ pub fn token_from_env() -> Result<String, LinearError> {
}
}

/// 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<String, LinearError> {
token_from_env().or_else(|_| token_from_file())
}

fn token_file_path() -> Result<PathBuf, LinearError> {
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::*;
Expand Down Expand Up @@ -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(""), || {
Expand All @@ -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"));
}
}
18 changes: 7 additions & 11 deletions crates/lineark-sdk/src/client.rs
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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, LinearError> {
Self::from_token(auth::token_from_file()?)
}

/// Create a client by auto-detecting the token (env -> file).
pub fn auto() -> Result<Self, LinearError> {
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, LinearError> {
Self::from_token(auth::token_from_file(path)?)
}

/// Execute a GraphQL query and extract a single object from the response.
Expand Down
4 changes: 2 additions & 2 deletions crates/lineark-sdk/src/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 2 additions & 4 deletions crates/lineark-test-utils/src/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
18 changes: 18 additions & 0 deletions crates/lineark/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,24 @@ 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 # "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 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

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`.
Expand Down
57 changes: 48 additions & 9 deletions crates/lineark/src/commands/usage.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,57 @@
use crate::profile;
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)"
} else {
""

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 = profile::display_path(active_name);

let (file_hint, profile_extra_lines) = match &home {
Some(h) => {
let found = profile::token_path(h, active_name).exists();

// Discover other profiles (excluding the active one).
let mut others: Vec<String> = Vec::new();
if h.join(".linear_api_token").exists() && active_name != "default" {
others.push("\"default\"".to_string());
}
for p in profile::discover(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 <name>",
others.join(", ")
)
};

(hint, extra)
}
None => (String::new(), String::new()),
};

print!(
Expand Down Expand Up @@ -75,12 +113,13 @@ COMMANDS:

GLOBAL OPTIONS:
--api-token <TOKEN> Override API token
--profile <NAME> Use API token from ~/.linear_api_token_<NAME>
--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}
"#
);

Expand Down
Loading