Skip to content

feat: multi-profile auth via --profile flag #126

@flipbit03

Description

@flipbit03

Summary

Add multi-profile support so users working across multiple Linear workspaces can store multiple API tokens and switch between them with --profile <name>. Profiles are simply different ~/.linear_api_token_* files — no config files, no TOML, no new dependencies. lineark stays config-free.

Inspired by #115 (thanks @lightstrike for the idea and effort). This issue captures the design we want to implement instead — same goal, different approach that preserves lineark's zero-config philosophy.


Design Decisions

1. Profile = named token file

Profiles are just ~/.linear_api_token_{name} files. No config file format, no parsing, no new dependencies.

Profile Token file
(default) ~/.linear_api_token
work ~/.linear_api_token_work
banana ~/.linear_api_token_banana

Rationale: lineark's design principle is "zero config for existing Linear users." A single token file already works today — multiple profiles are just more files following the same pattern. This avoids introducing TOML parsing, XDG config directories, or any new dependency.

2. --profile flag on CLI

lineark --profile work teams list     # uses ~/.linear_api_token_work
lineark teams list                    # uses default auth chain (env → ~/.linear_api_token)

--profile is a global flag (#[arg(long, global = true)]), same as --api-token and --format.

3. Auth precedence — --profile is an intentional override

When --profile is specified, it skips the env var and goes straight to the named file. The user explicitly asked for a specific profile — the env var should not silently win.

Full precedence chain:

With --profile:
  1. --api-token flag         (but conflicts_with --profile, so this is a hard error)
  2. ~/.linear_api_token_{profile} file

Without --profile:
  1. --api-token flag
  2. $LINEAR_API_TOKEN env var
  3. ~/.linear_api_token file

Rationale: --profile work means "I want the work token." If $LINEAR_API_TOKEN is also set, the user's intent is clear — they want the profile, not the env var. Letting the env var win would be surprising.

4. --api-token + --profile = hard error

These flags are mutually exclusive via clap's conflicts_with. If both are provided, clap errors before the program runs.

Rationale: Silent precedence creates confusion. If someone passes both, it's a mistake — better to catch it immediately. The codebase already uses conflicts_with in other commands (cycles.rs, labels.rs).

5. SDK becomes path-agnostic (breaking change)

The SDK currently bakes in ~/.linear_api_token via Client::auto() and Client::from_file(). This is a filesystem convention that belongs in the CLI, not the SDK. Library consumers should be able to read tokens from any path.

Remove from SDK:

  • Client::auto() — bakes in path convention
  • Client::from_file() — bakes in ~/.linear_api_token
  • auth::auto_token() — combines env + hardcoded path
  • auth::token_from_file() (no-arg version) — hardcoded path

Keep/add in SDK:

  • Client::from_token(token: impl Into<String>) — unchanged
  • Client::from_env() — reads $LINEAR_API_TOKEN, unchanged
  • Client::from_token_file(path: &Path)new, reads token from any file path (read, trim, good error messages with path included)
  • auth::token_from_env() — unchanged
  • auth::token_from_file(path: &Path)changed signature, accepts any path instead of hardcoding ~/.linear_api_token

Rationale: The SDK is a library. Library consumers may store tokens wherever they want. The ~/.linear_api_token convention is a CLI UX decision, not a library concern. This also cleanly separates profile logic: the CLI owns the naming convention, the SDK just reads files.

This is a breaking change. Current version → next major version.

6. CLI owns the convention

The CLI's client resolution in main.rs becomes:

let home = home::home_dir().expect("could not determine home directory");

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)) => {
        Client::from_token_file(&home.join(format!(".linear_api_token_{profile}")))
    }
    (None, None) => {
        Client::from_env()
            .or_else(|_| Client::from_token_file(&home.join(".linear_api_token")))
    }
};

7. Profile discovery in lineark usage

lineark usage currently shows hints like (set) / (found) for env var and token file. Update it to discover and list available profiles.

Discovery: glob ~/.linear_api_token_* (note the underscore — this avoids matching backup files like .linear_api_token.bak). Parse the suffix after _ as the profile name.

Filter: strip test from the displayed list. The _test file is for CI/online tests and would confuse users.

Output example:

AUTH (in precedence order):
  1. --api-token flag
  2. $LINEAR_API_TOKEN env var (set)
  3. ~/.linear_api_token file (found)

  found profiles: "default", "work", "banana". switch with --profile <name>

"default" appears when ~/.linear_api_token exists (no suffix = default profile).

8. Error UX when profile file is missing

When --profile work is specified but ~/.linear_api_token_work doesn't exist:

Error: Profile "work" not found. Available profiles: "default", "banana".
Create it with:
  echo "lin_api_..." > ~/.linear_api_token_work

If no profiles exist at all:

Error: Profile "work" not found. No profiles found.
Create it with:
  echo "lin_api_..." > ~/.linear_api_token_work

Rationale: Actionable error messages. The user knows exactly what to do and can see what's available.

9. Refactor test-utils to use SDK

lineark-test-utils currently hardcodes ~/.linear_api_token_test with its own file-reading logic. Refactor to use the SDK's token_from_file():

// Before (hardcoded path + own file reading)
pub fn test_token() -> String {
    let path = home::home_dir().unwrap().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()
}

// After (uses SDK)
pub fn test_token() -> String {
    let path = home::home_dir().unwrap().join(".linear_api_token_test");
    lineark_sdk::auth::token_from_file(&path)
        .unwrap_or_else(|e| panic!("could not read test token: {}", e))
}

Rationale: Single source of truth for reading token files. The SDK owns file reading (read, trim, error messages), consumers just provide a path.

10. No LINEAR_PROFILE env var

The PR proposed LINEAR_PROFILE env var support. We're not doing this. Profile is not a secret — it doesn't need the env var treatment that tokens get (avoiding ps aux leaks). The --profile flag is sufficient.


Implementation Plan

Step 1: SDK changes (crates/lineark-sdk/)

src/auth.rs:

  • Change token_from_file() signature to token_from_file(path: &Path) -> Result<String, LinearError>
  • Remove auto_token() (it combined env + hardcoded path)
  • Remove token_file_path() helper (hardcoded ~/.linear_api_token)
  • Keep token_from_env() unchanged
  • Update unit tests — remove tests for removed functions, update tests for new token_from_file(&Path) signature

src/client.rs:

  • Remove Client::auto()
  • Remove Client::from_file()
  • Add Client::from_token_file(path: &Path) -> Result<Self, LinearError>
  • Keep Client::from_token() and Client::from_env() unchanged

src/lib.rs:

  • Update re-exports if needed

src/helpers.rs:

  • Update doc examples that reference Client::auto()

README.md:

  • Update auth documentation table — remove auto row, add from_token_file row

Step 2: CLI changes (crates/lineark/)

src/main.rs:

  • Add --profile flag to Cli struct with conflicts_with = "api_token"
  • Update client resolution to the three-branch match (api_token / profile / default)
  • Add profile discovery helper (glob + filter) for error messages

src/commands/usage.rs:

  • Add profile discovery: glob ~/.linear_api_token_*, strip test, display found profiles
  • Update AUTH section to mention --profile

README.md:

  • Add multi-profile section with setup examples

Step 3: test-utils refactor (crates/lineark-test-utils/)

src/token.rs:

  • Replace hardcoded file reading with lineark_sdk::auth::token_from_file(&path)
  • Keep test_token() and no_online_test_token() public API unchanged

Step 4: Tests

SDK unit tests (crates/lineark-sdk/src/auth.rs):

  • token_from_file with valid file (tempfile)
  • token_from_file with missing file (good error message with path)
  • token_from_file with empty file
  • token_from_file trims whitespace
  • token_from_env unchanged tests

CLI offline tests (crates/lineark/tests/offline.rs):

  • --profile work with valid ~/.linear_api_token_work (use tempdir + HOME override)
  • --profile work with missing file → error message includes profile name, available profiles, creation hint
  • --profile + --api-token → clap error
  • --help shows --profile flag
  • lineark usage shows discovered profiles
  • Default (no flag) still works with ~/.linear_api_token
  • Default (no flag) still works with $LINEAR_API_TOKEN env var

Step 5: Documentation

  • Update lineark usage output
  • Update CLI README
  • Update SDK README
  • Run /update-docs before merging

Out of Scope

  • LINEAR_PROFILE env var — not needed, --profile flag suffices
  • TOML config file — lineark stays config-free
  • toml dependency — not needed
  • Profile switching within a session — one profile per invocation
  • SDK-level profile awareness — SDK is path-agnostic, CLI owns conventions

Version Impact

This is a breaking change to the SDK's public API (Client::auto() and Client::from_file() removed). Bump to next major version.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions